ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Voice Tasker preview и трейс активности
This commit is contained in:
parent
d867a89a1b
commit
9a91af372e
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ from .workspace.label import WorkspaceLabelsEndpoint
|
|||
from .workspace.state import WorkspaceStatesEndpoint
|
||||
from .workspace.user import (
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
WorkspaceActivityEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceUserProfileEndpoint,
|
||||
|
|
|
|||
|
|
@ -2589,6 +2589,85 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
if action == "create_task":
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
if issue.project_id != project.id:
|
||||
issue = move_voice_task_issue_to_project(issue, project, resolution["state"], request.user)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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"}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
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,30 +1085,47 @@ 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");
|
||||
if (notify) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: getCommitSuccessTitle(result),
|
||||
message: getCommitSuccessMessage(result),
|
||||
});
|
||||
handleClose();
|
||||
}
|
||||
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");
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const updateDraft = useCallback((patch: Partial<TVoiceTaskDraft>) => {
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue