ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Voice Tasker preview и трейс активности

This commit is contained in:
DCCONSTRUCTIONS 2026-04-26 20:41:38 +03:00
parent d867a89a1b
commit 9a91af372e
10 changed files with 588 additions and 289 deletions

View File

@ -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/<str:slug>/activity/",
WorkspaceActivityEndpoint.as_view(),
name="workspace-activity",
),
path(
"workspaces/<str:slug>/user-activity/<uuid:user_id>/",
WorkspaceUserActivityEndpoint.as_view(),

View File

@ -69,6 +69,7 @@ from .workspace.label import WorkspaceLabelsEndpoint
from .workspace.state import WorkspaceStatesEndpoint
from .workspace.user import (
UserLastProjectWithWorkspaceEndpoint,
WorkspaceActivityEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceUserProfileEndpoint,

View File

@ -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 {}

View File

@ -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")

View File

@ -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: <ProjectIcon height={16} width={16} />, i18n_key: "home.recents.filters.projects" },
];
const PRIORITY_LABELS: Record<string, string> = {
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 = (
<div className="group flex min-w-0 items-start gap-3 rounded-[18px] px-3 py-2.5 transition-colors hover:bg-white/[0.03]">
<Avatar
name={activity.actor_detail?.display_name}
src={getFileURL(activity.actor_detail?.avatar_url)}
size="sm"
shape="circle"
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 text-13 leading-5 text-secondary">
<span className="font-medium text-primary">{actorName}</span>{" "}
<span>{activityMessage(activity)}</span>{" "}
<span className="font-medium text-primary">
{issueIdentifier ? `${issueIdentifier}: ${issueTitle}` : issueTitle}
</span>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<span className="text-11 whitespace-nowrap text-placeholder">{calculateTimeAgo(activity.created_at)}</span>
<span className="grid size-6 place-items-center rounded-full bg-white/[0.04] text-tertiary">
{activity.field ? <ActivityIcon activity={activity} /> : <WorkItemsIcon className="size-3" />}
</span>
</div>
</div>
</div>
</div>
);
if (!issueLink) return content;
return (
<a href={issueLink} className="block">
{content}
</a>
);
}
export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) {
const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = props;
// states
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
const { t } = useTranslation();
const { data: currentUser } = useUser();
// ref
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
<div className="mb-4 flex items-center justify-between">
@ -108,8 +252,20 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
</div>
<div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto">
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading && recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
{isWidgetLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isWidgetLoading &&
shouldUseActivityTrace &&
activityTrace.map((activity) => (
<RecentActivityTraceItem
key={activity.id}
activity={activity}
currentUserId={currentUser?.id}
workspaceSlug={workspaceSlug}
/>
))}
{!isWidgetLoading &&
!shouldUseActivityTrace &&
recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
</div>
</div>
);

View File

@ -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<HTMLDivElement | null>(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 (
<div className="relative h-28 overflow-hidden rounded-[28px] bg-black/20 px-5">
<span className="absolute top-1/2 right-5 left-5 h-px -translate-y-1/2 bg-[rgb(var(--nodedc-accent-rgb))]/20" />
<div
className="relative z-10 grid h-full items-center justify-items-center gap-1"
style={{ gridTemplateColumns: `repeat(${VOICE_TASK_WAVEFORM_BAR_COUNT}, minmax(0, 1fr))` }}
>
{renderedLevels.map((level, index) => (
<span
key={index}
className={cn(
"w-1.5 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] transition-[height,opacity] duration-[55ms]",
!isRecording && "animate-pulse"
)}
style={{
height: `${Math.round(6 + Math.max(0, Math.min(1, level)) * 88)}px`,
opacity: isRecording ? 0.36 + Math.min(1, level) * 0.64 : 0.42,
animationDelay: `${index * 35}ms`,
}}
/>
))}
<div ref={containerRef} className="h-full w-full py-6">
<LiveAudioVisualizer
mediaRecorder={mediaRecorder}
width={visualizerWidth}
height={64}
barWidth={5}
gap={5}
backgroundColor="transparent"
barColor={accentColor}
fftSize={1024}
minDecibels={-92}
maxDecibels={-18}
smoothingTimeConstant={0.68}
/>
</div>
</div>
);
@ -426,12 +452,8 @@ function VoiceTaskWaveform({ isRecording, levels }: { isRecording: boolean; leve
function VoiceTaskProcessingState() {
return (
<div className="mt-6 flex items-center justify-center py-12">
<div className="relative flex size-28 items-center justify-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10">
<span className="absolute inset-2 rounded-full border border-[rgb(var(--nodedc-accent-rgb))]/20" />
<span className="absolute inset-0 rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10 blur-2xl" />
<LoaderCircle className="relative size-12 animate-spin text-[rgb(var(--nodedc-accent-rgb))]" />
</div>
<div className="mt-2 flex h-40 items-center justify-center">
<span className="nodedc-voice-task-processing-loader" aria-hidden="true" />
</div>
);
}
@ -731,10 +753,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const [duration, setDuration] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioLevels, setAudioLevels] = useState<number[]>([]);
const [recordingMediaRecorder, setRecordingMediaRecorder] = useState<MediaRecorder | null>(null);
const [error, setError] = useState<string | null>(null);
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
const [hasDraftChanges, setHasDraftChanges] = useState(false);
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
@ -744,9 +767,6 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const timerRef = useRef<number | null>(null);
const closeResetTimerRef = useRef<number | null>(null);
const startedAtRef = useRef(0);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const audioVisualizerFrameRef = useRef<number | null>(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) {
</div>
<div className="mt-1 text-12 text-tertiary">{recordingStatusLabel}</div>
</div>
{isRecording && (
<div className="flex size-14 items-center justify-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/15 text-[rgb(var(--nodedc-accent-rgb))]">
<Mic className="size-6 animate-pulse" />
</div>
)}
</div>
<div className="mt-4">
{isRecording ? (
<VoiceTaskWaveform isRecording={isRecording} levels={audioLevels} />
<VoiceTaskWaveform mediaRecorder={recordingMediaRecorder} />
) : audioUrl ? (
<VoiceTaskAudioPlayer audioUrl={audioUrl} />
) : (
@ -1546,7 +1478,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
</div>
)}
{commitResult?.task_key && (
{commitResult?.task_key && !hasDraftChanges && (
<div className="border-green-500/25 bg-green-500/10 text-green-300 rounded-[22px] border px-4 py-3 text-12">
{getCommitSuccessMessage(commitResult)}
</div>
@ -1565,7 +1497,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<Mic className="mr-2 size-4" />
Перезаписать
</Button>
{!commitResult?.task_id && (
{(!commitResult?.task_id || hasDraftChanges) && (
<Button
variant={draft.intent === "delete_task" ? "error-fill" : "primary"}
size="lg"
@ -1573,14 +1505,18 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
loading={isCommitting}
disabled={!canPublishDraft}
>
{draft.intent === "update_task" ? (
{commitResult?.task_id && hasDraftChanges ? (
<Pencil className="mr-2 size-4" />
) : draft.intent === "update_task" ? (
<Pencil className="mr-2 size-4" />
) : draft.intent === "delete_task" ? (
<Trash2 className="mr-2 size-4" />
) : (
<CheckCircle2 className="mr-2 size-4" />
)}
{getCommitButtonLabel(draft.intent)}
{commitResult?.task_id && hasDraftChanges
? "Применить изменения"
: getCommitButtonLabel(draft.intent)}
</Button>
)}
</div>

View File

@ -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<IUserActivityResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/activity/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
// widgets
async fetchWorkspaceWidgets(workspaceSlug: string): Promise<TWidgetEntityData[]> {
return this.get(`/api/workspaces/${workspaceSlug}/home-preferences/`)

View File

@ -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",

View File

@ -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%;
}
}
}

View File

@ -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)