From 9a91af372ef6faf75f35c9721e0e3ba72c19f5c0 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 26 Apr 2026 20:41:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20Voice=20Tasker=20preview=20=D0=B8?= =?UTF-8?q?=20=D1=82=D1=80=D0=B5=D0=B9=D1=81=20=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/api/plane/app/urls/workspace.py | 6 + .../apps/api/plane/app/views/__init__.py | 1 + .../apps/api/plane/app/views/voice_tasker.py | 189 ++++++--- .../api/plane/app/views/workspace/user.py | 29 ++ .../components/home/widgets/recents/index.tsx | 166 +++++++- .../voice-tasker/global-control.tsx | 394 ++++++++---------- .../web/core/services/workspace.service.ts | 18 + plane-src/apps/web/package.json | 1 + plane-src/apps/web/styles/globals.css | 59 +++ plane-src/pnpm-lock.yaml | 14 + 10 files changed, 588 insertions(+), 289 deletions(-) diff --git a/plane-src/apps/api/plane/app/urls/workspace.py b/plane-src/apps/api/plane/app/urls/workspace.py index d79d5a7..1a3b6b4 100644 --- a/plane-src/apps/api/plane/app/urls/workspace.py +++ b/plane-src/apps/api/plane/app/urls/workspace.py @@ -17,6 +17,7 @@ from plane.app.views import ( UserLastProjectWithWorkspaceEndpoint, WorkspaceThemeViewSet, WorkspaceUserProfileStatsEndpoint, + WorkspaceActivityEndpoint, WorkspaceUserActivityEndpoint, WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, @@ -134,6 +135,11 @@ urlpatterns = [ WorkspaceUserProfileStatsEndpoint.as_view(), name="workspace-user-stats", ), + path( + "workspaces//activity/", + WorkspaceActivityEndpoint.as_view(), + name="workspace-activity", + ), path( "workspaces//user-activity//", WorkspaceUserActivityEndpoint.as_view(), diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index e4bb24c..3d4b077 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -69,6 +69,7 @@ from .workspace.label import WorkspaceLabelsEndpoint from .workspace.state import WorkspaceStatesEndpoint from .workspace.user import ( UserLastProjectWithWorkspaceEndpoint, + WorkspaceActivityEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceUserPropertiesEndpoint, WorkspaceUserProfileEndpoint, diff --git a/plane-src/apps/api/plane/app/views/voice_tasker.py b/plane-src/apps/api/plane/app/views/voice_tasker.py index 893da9b..b4dd1e6 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -2589,66 +2589,145 @@ class VoiceTaskCommitEndpoint(BaseAPIView): ) if action == "create_task": - project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace) - payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript) - payload_without_project = {key: value for key, value in payload.items() if key != "project_id"} - payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE - payload_without_project["external_id"] = str(voice_session.id) + existing_issue = voice_session.created_task + if is_voice_task_issue_available(existing_issue): + issue = existing_issue + if not can_user_update_voice_task_issue(request.user, workspace, issue): + return Response( + { + "ok": False, + "code": "issue_permission_denied", + "error": "Voice Task draft could not update the created work item.", + }, + status=status.HTTP_403_FORBIDDEN, + ) - serializer = IssueCreateSerializer( - data=payload_without_project, - context={ - "project_id": project.id, - "workspace_id": workspace.id, - "default_assignee_id": project.default_assignee_id, - }, - ) - if not serializer.is_valid(): - return Response( - { - "ok": False, - "code": "issue_validation_failed", - "error": "Voice Task draft could not be converted to a work item.", - "details": serializer.errors, - }, - status=status.HTTP_400_BAD_REQUEST, + project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace) + payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript) + payload_without_project = {key: value for key, value in payload.items() if key != "project_id"} + payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE + payload_without_project["external_id"] = str(voice_session.id) + current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder) + requested_data = json.dumps( + {**payload_without_project, "project_id": str(project.id)}, + cls=DjangoJSONEncoder, ) - issue = serializer.save(created_by_id=request.user.id) - voice_session.created_task = issue - voice_session.parsed_json = draft - voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"]) + if issue.project_id != project.id: + issue = move_voice_task_issue_to_project(issue, project, resolution["state"], request.user) - requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder) - issue_activity.delay( - type="issue.activity.created", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project.id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - model_activity.delay( - model_name="issue", - model_id=str(issue.id), - requested_data=payload_without_project, - current_instance=None, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - issue_description_version_task.delay( - updated_issue=requested_data, - issue_id=str(issue.id), - user_id=request.user.id, - is_creating=True, - ) + serializer = IssueCreateSerializer( + issue, + data=payload_without_project, + partial=True, + context={"project_id": project.id}, + ) + if not serializer.is_valid(): + return Response( + { + "ok": False, + "code": "issue_validation_failed", + "error": "Voice Task draft could not update the created work item.", + "details": serializer.errors, + }, + status=status.HTTP_400_BAD_REQUEST, + ) - response_status = status.HTTP_201_CREATED - commit_status = "created" + serializer.save() + issue.refresh_from_db() + voice_session.created_task = issue + voice_session.parsed_json = draft + voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"]) + + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project.id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + model_activity.delay( + model_name="issue", + model_id=str(issue.id), + requested_data=json.loads(requested_data), + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + issue_description_version_task.delay( + updated_issue=current_instance, + issue_id=str(issue.id), + user_id=request.user.id, + ) + + response_status = status.HTTP_200_OK + commit_status = "updated" + else: + project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace) + payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript) + payload_without_project = {key: value for key, value in payload.items() if key != "project_id"} + payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE + payload_without_project["external_id"] = str(voice_session.id) + + serializer = IssueCreateSerializer( + data=payload_without_project, + context={ + "project_id": project.id, + "workspace_id": workspace.id, + "default_assignee_id": project.default_assignee_id, + }, + ) + if not serializer.is_valid(): + return Response( + { + "ok": False, + "code": "issue_validation_failed", + "error": "Voice Task draft could not be converted to a work item.", + "details": serializer.errors, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue = serializer.save(created_by_id=request.user.id) + voice_session.created_task = issue + voice_session.parsed_json = draft + voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"]) + + requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder) + issue_activity.delay( + type="issue.activity.created", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project.id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + model_activity.delay( + model_name="issue", + model_id=str(issue.id), + requested_data=payload_without_project, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + issue_description_version_task.delay( + updated_issue=requested_data, + issue_id=str(issue.id), + user_id=request.user.id, + is_creating=True, + ) + + response_status = status.HTTP_201_CREATED + commit_status = "created" elif action == "update_task": target_task = resolution.get("target_task") or {} diff --git a/plane-src/apps/api/plane/app/views/workspace/user.py b/plane-src/apps/api/plane/app/views/workspace/user.py index 2392939..91cae64 100644 --- a/plane-src/apps/api/plane/app/views/workspace/user.py +++ b/plane-src/apps/api/plane/app/views/workspace/user.py @@ -406,6 +406,35 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): ) +class WorkspaceActivityEndpoint(BaseAPIView): + permission_classes = [WorkspaceEntityPermission] + + def get(self, request, slug): + projects = request.query_params.getlist("project", []) + + queryset = ( + IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .select_related("actor", "workspace", "issue", "project") + .distinct() + ) + + if projects: + queryset = queryset.filter(project__in=projects) + + return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), + request=request, + queryset=queryset, + on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data, + ) + + class WorkspaceUserProfileStatsEndpoint(BaseAPIView): def get(self, request, slug, user_id): filters = issue_filters(request.query_params, "GET") diff --git a/plane-src/apps/web/core/components/home/widgets/recents/index.tsx b/plane-src/apps/web/core/components/home/widgets/recents/index.tsx index e76fe22..2cd75dd 100644 --- a/plane-src/apps/web/core/components/home/widgets/recents/index.tsx +++ b/plane-src/apps/web/core/components/home/widgets/recents/index.tsx @@ -10,9 +10,13 @@ import useSWR from "swr"; import { useTranslation } from "@plane/i18n"; // plane types import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons"; -import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; +import { Avatar } from "@plane/propel/avatar"; +import type { IIssueActivity, TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; +import { calculateTimeAgo, generateWorkItemLink, getFileURL, renderFormattedDate } from "@plane/utils"; +import { ActivityIcon } from "@/components/core/activity"; // plane web services import { WorkspaceService } from "@/services/workspace.service"; +import { useUser } from "@/hooks/store/user"; import { getActivityProjectId } from "../../home.utils"; import { RecentsEmptyState } from "../empty-states"; import { EWidgetKeys, WidgetLoader } from "../loaders"; @@ -30,6 +34,55 @@ const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_k { name: "project", icon: , i18n_key: "home.recents.filters.projects" }, ]; +const PRIORITY_LABELS: Record = { + urgent: "Срочный", + high: "Высокий", + medium: "Средний", + low: "Низкий", + none: "Без приоритета", +}; + +const activityValue = (value: string | null | undefined) => { + if (!value) return null; + return PRIORITY_LABELS[value] ?? value; +}; + +const activityDate = (value: string | null | undefined) => { + if (!value) return null; + return renderFormattedDate(value); +}; + +const activityMessage = (activity: IIssueActivity) => { + if (!activity.field && activity.verb === "created") return "создал рабочий элемент"; + + switch (activity.field) { + case "assignees": + return activity.old_value === "" + ? `добавил исполнителя ${activityValue(activity.new_value) ?? ""}`.trim() + : `убрал исполнителя ${activityValue(activity.old_value) ?? ""}`.trim(); + case "state": + return `изменил статус на ${activityValue(activity.new_value) ?? "новое значение"}`; + case "priority": + return `изменил приоритет на ${activityValue(activity.new_value) ?? "новое значение"}`; + case "target_date": + return `изменил срок на ${activityDate(activity.new_value) ?? "новую дату"}`; + case "start_date": + return `изменил дату начала на ${activityDate(activity.new_value) ?? "новую дату"}`; + case "name": + return "изменил название"; + case "description": + return "обновил описание"; + case "labels": + return "изменил метки"; + case "estimate_point": + return "изменил оценку"; + case "archived_at": + return activity.new_value === "restore" ? "восстановил рабочий элемент" : "архивировал рабочий элемент"; + default: + return "обновил рабочий элемент"; + } +}; + type TRecentWidgetProps = THomeWidgetProps & { presetFilter?: TRecentActivityFilterKeys; showFilterSelect?: boolean; @@ -37,16 +90,102 @@ type TRecentWidgetProps = THomeWidgetProps & { recents?: TActivityEntityData[]; }; +function RecentActivityTraceItem({ + activity, + currentUserId, + workspaceSlug, +}: { + activity: IIssueActivity; + currentUserId?: string; + workspaceSlug: string; +}) { + const actorName = + currentUserId === activity.actor_detail?.id ? "Вы" : activity.actor_detail?.display_name || "Пользователь"; + const issueIdentifier = + activity.project_detail?.identifier && activity.issue_detail?.sequence_id + ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` + : null; + const issueTitle = activity.issue_detail?.name ?? "рабочем элементе"; + const issueLink = + activity.issue && activity.issue_detail + ? generateWorkItemLink({ + workspaceSlug, + projectId: activity.project, + issueId: activity.issue, + projectIdentifier: activity.project_detail?.identifier, + sequenceId: activity.issue_detail?.sequence_id, + }) + : null; + + const content = ( +
+ +
+
+
+ {actorName}{" "} + {activityMessage(activity)}{" "} + + {issueIdentifier ? `${issueIdentifier}: ${issueTitle}` : issueTitle} + +
+
+ {calculateTimeAgo(activity.created_at)} + + {activity.field ? : } + +
+
+
+
+ ); + + if (!issueLink) return content; + + return ( + + {content} + + ); +} + export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) { const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = props; // states const [filter, setFilter] = useState(presetFilter ?? filters[0].name); const { t } = useTranslation(); + const { data: currentUser } = useUser(); // ref const ref = useRef(null); + const shouldUseActivityTrace = filter === filters[0].name || filter === "issue"; + + const { data: fetchedActivity, isLoading: isActivityLoading } = useSWR( + workspaceSlug && shouldUseActivityTrace + ? `WORKSPACE_ACTIVITY_TRACE_${workspaceSlug}_${projectId ?? "all"}` + : null, + workspaceSlug + ? () => + workspaceService.fetchWorkspaceActivity(workspaceSlug.toString(), { + per_page: 20, + ...(projectId ? { project: projectId } : {}), + }) + : null, + { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); const { data: fetchedRecents, isLoading } = useSWR( - workspaceSlug && !preloadedRecents ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null, + workspaceSlug && !preloadedRecents && !shouldUseActivityTrace + ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` + : null, workspaceSlug ? () => workspaceService.fetchWorkspaceRecents( @@ -61,6 +200,8 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props } ); + const activityTrace = useMemo(() => fetchedActivity?.results ?? [], [fetchedActivity?.results]); + const recents = useMemo(() => { const source = preloadedRecents ?? fetchedRecents ?? []; const filteredByType = source.filter((activity) => @@ -74,6 +215,9 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props }); }, [fetchedRecents, filter, preloadedRecents, projectId]); + const isWidgetLoading = shouldUseActivityTrace ? isActivityLoading : isLoading; + const isWidgetEmpty = shouldUseActivityTrace ? activityTrace.length === 0 : recents.length === 0; + const resolveRecent = (activity: TActivityEntityData) => { switch (activity.entity_name) { case "page": @@ -88,7 +232,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props } }; - if (!isLoading && recents.length === 0) + if (!isWidgetLoading && isWidgetEmpty) return (
@@ -108,8 +252,20 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props {showFilterSelect && }
- {isLoading && } - {!isLoading && recents.map((activity) =>
{resolveRecent(activity)}
)} + {isWidgetLoading && } + {!isWidgetLoading && + shouldUseActivityTrace && + activityTrace.map((activity) => ( + + ))} + {!isWidgetLoading && + !shouldUseActivityTrace && + recents.map((activity) =>
{resolveRecent(activity)}
)}
); diff --git a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx index c692842..47d1cb3 100644 --- a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx +++ b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ElementType, MouseEvent, ReactNode } from "react"; import { useParams } from "next/navigation"; +import { LiveAudioVisualizer } from "react-audio-visualize"; import useSWR from "swr"; import { AudioLines, @@ -17,7 +18,6 @@ import { Flag, FolderKanban, ListChecks, - LoaderCircle, Mic, Pause, Pencil, @@ -86,14 +86,6 @@ const voiceTaskCloseButtonClassName = const VOICE_TASK_TIME_HOURS = Array.from({ length: 24 }, (_, index) => index.toString().padStart(2, "0")); const VOICE_TASK_TIME_MINUTES = Array.from({ length: 60 }, (_, index) => index.toString().padStart(2, "0")); const VOICE_TASK_TIME_WHEEL_ITEM_HEIGHT = 36; -const VOICE_TASK_WAVEFORM_BAR_COUNT = 32; -const VOICE_TASK_SPEECH_MIN_HZ = 85; -const VOICE_TASK_SPEECH_MAX_HZ = 3800; - -function clampVoiceTaskLevel(value: number) { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(1, value)); -} function getSupportedMimeType() { if (typeof MediaRecorder === "undefined") return ""; @@ -194,6 +186,21 @@ function getVoiceTaskAssigneeIds(result: TVoiceTaskUploadResult | null) { return resolvedAssignees.flatMap((assignee) => (assignee?.id ? [assignee.id] : [])); } +function hydrateVoiceTaskDraftFromResolution(result: TVoiceTaskUploadResult): TVoiceTaskUploadResult { + if (!result.draft) return result; + + const resolvedAssigneeIds = getVoiceTaskAssigneeIds(result); + if (result.draft.assignee_ids?.length || resolvedAssigneeIds.length === 0) return result; + + return { + ...result, + draft: { + ...result.draft, + assignee_ids: resolvedAssigneeIds, + }, + }; +} + function getPriorityLabel(priority: TVoiceTaskPriority) { return priority ? PRIORITY_LABELS[priority] : "Не распознано"; } @@ -394,31 +401,50 @@ function VoiceTaskAudioPlayer({ audioUrl }: { audioUrl: string }) { ); } -function VoiceTaskWaveform({ isRecording, levels }: { isRecording: boolean; levels: number[] }) { - const fallbackLevels = useMemo(() => Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, () => 0), []); - const renderedLevels = isRecording && levels.length ? levels : fallbackLevels; +function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | null }) { + const containerRef = useRef(null); + const [visualizerWidth, setVisualizerWidth] = useState(360); + const [accentColor, setAccentColor] = useState("rgb(185, 255, 82)"); + + useEffect(() => { + const root = document.documentElement; + const accentRgb = getComputedStyle(root).getPropertyValue("--nodedc-accent-rgb").trim(); + const channels = accentRgb.split(/[,\s]+/).filter(Boolean).slice(0, 3); + + if (channels.length === 3) setAccentColor(`rgb(${channels.join(", ")})`); + }, []); + + useEffect(() => { + const node = containerRef.current; + if (!node || typeof ResizeObserver === "undefined") return; + + const updateWidth = () => setVisualizerWidth(Math.max(1, Math.floor(node.getBoundingClientRect().width))); + updateWidth(); + + const observer = new ResizeObserver(updateWidth); + observer.observe(node); + + return () => observer.disconnect(); + }, []); + + if (!mediaRecorder) return null; return (
- -
- {renderedLevels.map((level, index) => ( - - ))} +
+
); @@ -426,12 +452,8 @@ function VoiceTaskWaveform({ isRecording, levels }: { isRecording: boolean; leve function VoiceTaskProcessingState() { return ( -
-
- - - -
+
+
); } @@ -731,10 +753,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const [duration, setDuration] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [audioUrl, setAudioUrl] = useState(null); - const [audioLevels, setAudioLevels] = useState([]); + const [recordingMediaRecorder, setRecordingMediaRecorder] = useState(null); const [error, setError] = useState(null); const [parseResult, setParseResult] = useState(null); const [commitResult, setCommitResult] = useState(null); + const [hasDraftChanges, setHasDraftChanges] = useState(false); const [selectedTargetIssue, setSelectedTargetIssue] = useState(null); const mediaRecorderRef = useRef(null); @@ -744,9 +767,6 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const timerRef = useRef(null); const closeResetTimerRef = useRef(null); const startedAtRef = useRef(0); - const audioContextRef = useRef(null); - const audioSourceRef = useRef(null); - const audioVisualizerFrameRef = useRef(null); const { data: preflight } = useSWR( workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}` : null, @@ -780,146 +800,6 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { } }, []); - const stopAudioVisualization = useCallback(() => { - if (audioVisualizerFrameRef.current) { - window.cancelAnimationFrame(audioVisualizerFrameRef.current); - audioVisualizerFrameRef.current = null; - } - - audioSourceRef.current?.disconnect(); - audioSourceRef.current = null; - - if (audioContextRef.current && audioContextRef.current.state !== "closed") { - void audioContextRef.current.close(); - } - audioContextRef.current = null; - setAudioLevels([]); - }, []); - - const startAudioVisualization = useCallback( - (stream: MediaStream) => { - stopAudioVisualization(); - - const AudioContextClass = - window.AudioContext || - (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!AudioContextClass) return; - - const audioContext = new AudioContextClass(); - const analyser = audioContext.createAnalyser(); - const source = audioContext.createMediaStreamSource(stream); - const previousLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, () => 0); - let smoothedVoiceLevel = 0; - let rollingNoiseFloor = 0.035; - let rollingVoiceCeiling = 0.28; - - analyser.fftSize = 1024; - analyser.minDecibels = -90; - analyser.maxDecibels = -12; - analyser.smoothingTimeConstant = 0.38; - const frequencyData = new Uint8Array(analyser.frequencyBinCount); - const timeDomainData = new Uint8Array(analyser.fftSize); - source.connect(analyser); - audioContextRef.current = audioContext; - audioSourceRef.current = source; - void audioContext.resume(); - - const tick = () => { - analyser.getByteFrequencyData(frequencyData); - analyser.getByteTimeDomainData(timeDomainData); - - let rmsSum = 0; - for (const value of timeDomainData) { - const centeredValue = (value - 128) / 128; - rmsSum += centeredValue * centeredValue; - } - - const rms = Math.sqrt(rmsSum / timeDomainData.length); - const nyquist = audioContext.sampleRate / 2; - const voiceStartBin = Math.max(1, Math.floor((VOICE_TASK_SPEECH_MIN_HZ / nyquist) * frequencyData.length)); - const voiceEndBin = Math.min( - frequencyData.length - 1, - Math.ceil((VOICE_TASK_SPEECH_MAX_HZ / nyquist) * frequencyData.length) - ); - let bandEnergy = 0; - let bandWeight = 0; - - for (let bin = voiceStartBin; bin <= voiceEndBin; bin++) { - const frequency = (bin / frequencyData.length) * nyquist; - const voiceWeight = frequency < 160 ? 0.55 : frequency > 3200 ? 0.65 : frequency < 260 ? 0.82 : 1; - bandEnergy += Math.pow(frequencyData[bin] / 255, 1.12) * voiceWeight; - bandWeight += voiceWeight; - } - - const frequencyEnergy = bandWeight > 0 ? bandEnergy / bandWeight : 0; - const rmsEnergy = clampVoiceTaskLevel((rms - 0.006) / 0.13); - const rawVoiceEnergy = frequencyEnergy * 0.68 + rmsEnergy * 0.32; - const floorSpeed = rawVoiceEnergy > rollingNoiseFloor ? 0.004 : 0.045; - rollingNoiseFloor = clampVoiceTaskLevel(rollingNoiseFloor + (rawVoiceEnergy - rollingNoiseFloor) * floorSpeed); - rollingNoiseFloor = Math.max(0.018, Math.min(0.12, rollingNoiseFloor)); - rollingVoiceCeiling = - rawVoiceEnergy > rollingVoiceCeiling - ? rollingVoiceCeiling + (rawVoiceEnergy - rollingVoiceCeiling) * 0.08 - : rollingVoiceCeiling * 0.996 + 0.001; - rollingVoiceCeiling = Math.max(rollingNoiseFloor + 0.16, Math.min(0.58, rollingVoiceCeiling)); - - const adaptiveRange = Math.max(0.14, rollingVoiceCeiling - rollingNoiseFloor); - const absoluteVoice = clampVoiceTaskLevel((rawVoiceEnergy - 0.026) / 0.26); - const adaptiveVoice = clampVoiceTaskLevel((rawVoiceEnergy - rollingNoiseFloor * 1.12) / adaptiveRange); - const isMutedFrame = rawVoiceEnergy < rollingNoiseFloor + 0.012 && rms < 0.012; - const compressedVoice = isMutedFrame - ? 0 - : (1 - Math.exp(-(absoluteVoice * 0.66 + adaptiveVoice * 0.34) * 1.62)) * 0.92; - const voiceAttack = compressedVoice > smoothedVoiceLevel ? 0.46 : 0.2; - smoothedVoiceLevel += (compressedVoice - smoothedVoiceLevel) * voiceAttack; - - const now = performance.now(); - const phase = now / 72; - - const nextLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, (_, index) => { - const barPosition = index / Math.max(1, VOICE_TASK_WAVEFORM_BAR_COUNT - 1); - const frequencyOffset = Math.sin(barPosition * Math.PI * 0.5); - const bandCenter = voiceStartBin + Math.floor((voiceEndBin - voiceStartBin) * frequencyOffset); - const bandRadius = Math.max(1, Math.floor((voiceEndBin - voiceStartBin) / VOICE_TASK_WAVEFORM_BAR_COUNT)); - let localFrequencyEnergy = 0; - let localFrequencyCount = 0; - - for ( - let bin = Math.max(voiceStartBin, bandCenter - bandRadius); - bin <= Math.min(voiceEndBin, bandCenter + bandRadius); - bin++ - ) { - localFrequencyEnergy += frequencyData[bin] / 255; - localFrequencyCount++; - } - - const spectralTexture = - localFrequencyCount > 0 - ? clampVoiceTaskLevel((localFrequencyEnergy / localFrequencyCount - rollingNoiseFloor) / adaptiveRange) - : 0; - const centerLift = Math.sin(barPosition * Math.PI); - const shapeWeight = 0.72 + Math.pow(centerLift, 0.8) * 0.28; - const motion = 0.92 + Math.sin(phase + index * 0.78) * 0.045 + Math.sin(phase * 1.47 + index * 1.73) * 0.025; - const targetLevel = clampVoiceTaskLevel( - (smoothedVoiceLevel * 0.78 + spectralTexture * smoothedVoiceLevel * 0.22) * shapeWeight * motion - ); - const previousLevel = previousLevels[index] ?? 0; - const smoothing = targetLevel > previousLevel ? 0.52 : 0.24; - const smoothedLevel = previousLevel + (targetLevel - previousLevel) * smoothing; - - previousLevels[index] = smoothedLevel; - return smoothedLevel; - }); - - setAudioLevels(nextLevels); - audioVisualizerFrameRef.current = window.requestAnimationFrame(tick); - }; - - tick(); - }, - [stopAudioVisualization] - ); - const stopStream = useCallback(() => { streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; @@ -928,11 +808,12 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const resetVoiceTaskState = useCallback(() => { setAudioBlob(null); setAudioUrl(null); - setAudioLevels([]); + setRecordingMediaRecorder(null); setDuration(0); setError(null); setParseResult(null); setCommitResult(null); + setHasDraftChanges(false); setSelectedTargetIssue(null); setStatus("idle"); }, []); @@ -950,17 +831,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { if (recorder && recorder.state === "recording") { recorder.stop(); if (discard) { - stopAudioVisualization(); stopStream(); } + setRecordingMediaRecorder(null); return; } - stopAudioVisualization(); stopStream(); mediaRecorderRef.current = null; + setRecordingMediaRecorder(null); }, - [clearTimer, stopAudioVisualization, stopStream] + [clearTimer, stopStream] ); const resetRecording = useCallback(() => { @@ -989,10 +870,9 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { () => () => { clearTimer(); clearCloseResetTimer(); - stopAudioVisualization(); stopStream(); }, - [clearCloseResetTimer, clearTimer, stopAudioVisualization, stopStream] + [clearCloseResetTimer, clearTimer, stopStream] ); useEffect(() => { @@ -1044,8 +924,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { chunksRef.current = []; mediaRecorderRef.current = null; + setRecordingMediaRecorder(null); if (isDiscarded) discardedRecorderRef.current = null; - stopAudioVisualization(); stopStream(); if (isDiscarded || !chunks.length) return; @@ -1055,7 +935,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { }; recorder.start(); - startAudioVisualization(stream); + setRecordingMediaRecorder(recorder); startedAtRef.current = Date.now(); setDuration(0); setError(null); @@ -1069,7 +949,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { } catch { setError("Не удалось получить доступ к микрофону."); setStatus("error"); - stopAudioVisualization(); + setRecordingMediaRecorder(null); stopStream(); clearTimer(); } @@ -1082,6 +962,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { setError(null); setParseResult(null); setCommitResult(null); + setHasDraftChanges(false); const audioType = audioBlob.type || "audio/webm"; const extension = audioType.includes("mp4") ? "m4a" : "webm"; @@ -1099,15 +980,43 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { ); try { - const result = await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData); + const result = hydrateVoiceTaskDraftFromResolution( + await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData) + ); setParseResult(result); setSelectedTargetIssue(getTargetOptionFromResolution(result)); setStatus("success"); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Черновик готов", - message: "Transcript и draft получены.", - }); + setHasDraftChanges(false); + + const shouldAutoCommit = + result.voice_session_id && result.draft?.intent === "create_task" && result.resolution?.can_commit; + + if (shouldAutoCommit) { + try { + await commitVoiceTaskResult(result, { confirmDelete: false, notify: false }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Карточка добавлена в Kanban", + message: "Можно проверить поля и применить правки к уже созданной задаче.", + }); + } catch (err) { + const message = + typeof err === "object" && err && "error" in err + ? String(err.error) + : "Карточку распознали, но не смогли автоматически добавить в Kanban."; + setToast({ + type: TOAST_TYPE.ERROR, + title: "Черновик готов", + message, + }); + } + } else { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Черновик готов", + message: "Transcript и draft получены.", + }); + } } catch (err) { const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио."; @@ -1147,17 +1056,28 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { workspaceSlug, ]); - const commitVoiceTask = async () => { - if (!parseResult?.voice_session_id || !parseResult.draft) return; + async function commitVoiceTaskResult( + sourceResult: TVoiceTaskUploadResult, + { + closeOnSuccess = false, + confirmDelete = true, + notify = true, + }: { + closeOnSuccess?: boolean; + confirmDelete?: boolean; + notify?: boolean; + } = {} + ) { + if (!sourceResult.voice_session_id || !sourceResult.draft) return null; - const action = parseResult.draft.intent; - if (action === "unknown") return; + const action = sourceResult.draft.intent; + if (action === "unknown") return null; - if (action === "delete_task") { + if (action === "delete_task" && confirmDelete) { const targetTitle = - parseResult.resolution?.target_task?.key || parseResult.resolution?.target_task?.title || "последнюю задачу"; + sourceResult.resolution?.target_task?.key || sourceResult.resolution?.target_task?.title || "последнюю задачу"; const confirmed = window.confirm(`Удалить ${targetTitle}?`); - if (!confirmed) return; + if (!confirmed) return null; } setStatus("committing"); @@ -1165,29 +1085,46 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { try { const result = await workspaceAIService.commitVoiceTask(workspaceSlug, { - voice_session_id: parseResult.voice_session_id, + voice_session_id: sourceResult.voice_session_id, action, - draft: parseResult.draft, + draft: sourceResult.draft, }); await refreshVisibleIssueStores(); setCommitResult(result); + setHasDraftChanges(false); setStatus("committed"); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: getCommitSuccessTitle(result), - message: getCommitSuccessMessage(result), - }); - handleClose(); + if (notify) { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: getCommitSuccessTitle(result), + message: getCommitSuccessMessage(result), + }); + } + if (closeOnSuccess) handleClose(); + return result; } catch (err) { const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task."; setError(message); - setStatus("error"); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Voice Task не применен", - message, - }); + setStatus("success"); + if (notify) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Voice Task не применен", + message, + }); + } + throw err; + } + } + + const commitVoiceTask = async () => { + if (!parseResult?.voice_session_id || !parseResult.draft) return; + + try { + await commitVoiceTaskResult(parseResult, { closeOnSuccess: !commitResult?.task_id }); + } catch { + return; } }; @@ -1202,7 +1139,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { }, }; }); - setCommitResult(null); + setHasDraftChanges(true); }, []); const draft = parseResult?.draft; @@ -1225,7 +1162,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { draft.intent !== "unknown" && !isUploading && !isCommitting && - !commitResult?.task_id && + (!commitResult?.task_id || hasDraftChanges) && (draft.intent === "create_task" ? selectedProjectId && draft.title?.trim() : selectedTargetTaskId && (draft.intent === "delete_task" || selectedProjectId)) @@ -1281,16 +1218,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{recordingStatusLabel}
- {isRecording && ( -
- -
- )}
{isRecording ? ( - + ) : audioUrl ? ( ) : ( @@ -1546,7 +1478,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)} - {commitResult?.task_key && ( + {commitResult?.task_key && !hasDraftChanges && (
{getCommitSuccessMessage(commitResult)}
@@ -1565,7 +1497,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { Перезаписать - {!commitResult?.task_id && ( + {(!commitResult?.task_id || hasDraftChanges) && ( )} diff --git a/plane-src/apps/web/core/services/workspace.service.ts b/plane-src/apps/web/core/services/workspace.service.ts index 3c25653..d712a36 100644 --- a/plane-src/apps/web/core/services/workspace.service.ts +++ b/plane-src/apps/web/core/services/workspace.service.ts @@ -23,6 +23,7 @@ import type { TSearchEntityRequestPayload, TWidgetEntityData, TActivityEntityData, + IUserActivityResponse, IWorkspaceSidebarNavigationItem, IWorkspaceSidebarNavigation, IWorkspaceUserPropertiesResponse, @@ -353,6 +354,23 @@ export class WorkspaceService extends APIService { }); } + async fetchWorkspaceActivity( + workspaceSlug: string, + params: { + per_page: number; + cursor?: string; + project?: string; + } + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/activity/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + // widgets async fetchWorkspaceWidgets(workspaceSlug: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/home-preferences/`) diff --git a/plane-src/apps/web/package.json b/plane-src/apps/web/package.json index fa42d14..da07bec 100644 --- a/plane-src/apps/web/package.json +++ b/plane-src/apps/web/package.json @@ -55,6 +55,7 @@ "next-themes": "0.4.6", "pdfjs-dist": "5.4.296", "react": "catalog:", + "react-audio-visualize": "1.2.0", "react-color": "^2.19.3", "react-dom": "catalog:", "react-dropzone": "^14.2.3", diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index a4e3418..8c6e7b4 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -3936,4 +3936,63 @@ linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important; color: var(--text-color-primary) !important; } + + .nodedc-voice-task-processing-loader { + width: 5rem; + aspect-ratio: 1; + box-sizing: border-box; + position: relative; + display: block; + color: rgb(var(--nodedc-accent-rgb)); + } + + .nodedc-voice-task-processing-loader::before, + .nodedc-voice-task-processing-loader::after { + content: ""; + position: absolute; + box-sizing: border-box; + display: block; + } + + .nodedc-voice-task-processing-loader::before { + inset: 1.125rem; + border: 0.5rem solid currentColor; + border-radius: 0.625rem; + box-shadow: 0 0 1.25rem rgba(var(--nodedc-accent-rgb), 0.18); + } + + .nodedc-voice-task-processing-loader::after { + width: 1rem; + aspect-ratio: 1; + top: 0; + left: 0; + border-radius: 9999px; + background: currentColor; + box-shadow: 0 0 1.125rem rgba(var(--nodedc-accent-rgb), 0.28); + offset-anchor: center; + offset-path: path("M 22 22 H 58 V 58 H 22 V 22"); + animation: nodedc-voice-task-processing-loader 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite; + } + + @keyframes nodedc-voice-task-processing-loader { + 0% { + offset-distance: 0%; + } + + 25% { + offset-distance: 25%; + } + + 50% { + offset-distance: 50%; + } + + 75% { + offset-distance: 75%; + } + + 100% { + offset-distance: 100%; + } + } } diff --git a/plane-src/pnpm-lock.yaml b/plane-src/pnpm-lock.yaml index f7d136d..c32ef7a 100644 --- a/plane-src/pnpm-lock.yaml +++ b/plane-src/pnpm-lock.yaml @@ -668,6 +668,9 @@ importers: react: specifier: 'catalog:' version: 18.3.1 + react-audio-visualize: + specifier: 1.2.0 + version: 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-color: specifier: ^2.19.3 version: 2.19.3(react@18.3.1) @@ -7464,6 +7467,12 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-audio-visualize@1.2.0: + resolution: {integrity: sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ==} + peerDependencies: + react: '>=16.2.0' + react-dom: '>=16.2.0' + react-color@2.19.3: resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==} peerDependencies: @@ -15074,6 +15083,11 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-audio-visualize@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-color@2.19.3(react@18.3.1): dependencies: '@icons/material': 0.2.4(react@18.3.1)