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

This commit is contained in:
DCCONSTRUCTIONS 2026-04-26 16:14:49 +03:00
parent a13ff3b954
commit 323b4b964e
4 changed files with 801 additions and 209 deletions

View File

@ -691,6 +691,8 @@ def harden_voice_task_intent(parsed, transcript):
if parsed.get("intent") != "update_task":
return parsed
if parsed.get("target_task_id"):
return parsed
target_memory_ref = normalize_string(parsed.get("target_memory_ref"), 80)
has_explicit_issue_ref = bool(parse_issue_key_reference(target_memory_ref)) or transcript_has_issue_key_reference(
@ -716,6 +718,9 @@ def harden_voice_task_intent(parsed, transcript):
def voice_task_has_safe_existing_task_anchor(draft, transcript):
if draft.get("target_task_id"):
return True
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
return bool(
parse_issue_key_reference(target_memory_ref)
@ -971,6 +976,26 @@ def resolve_voice_task_assignee(project, draft):
return serialize_resolved_assignee(best_member, best_score, "assignee_hint")
def resolve_explicit_voice_task_assignees(project, draft):
if "assignee_ids" not in draft:
return None
assignee_ids = draft.get("assignee_ids") if isinstance(draft.get("assignee_ids"), list) else []
if not assignee_ids:
return []
member_by_id = {
str(project_member.member_id): project_member.member
for project_member in get_voice_task_project_assignable_members(project)
}
return [
serialize_resolved_assignee(member_by_id[assignee_id], 1.0, "explicit_assignee_ids")
for assignee_id in assignee_ids
if assignee_id in member_by_id
]
def transcript_requests_all_project_assignees(transcript, draft=None):
normalized = normalize_match_value(transcript)
assignee_hint = normalize_match_value((draft or {}).get("assignee_hint"))
@ -988,6 +1013,10 @@ def transcript_requests_all_project_assignees(transcript, draft=None):
def resolve_voice_task_assignees(project, draft, transcript=None):
explicit_assignees = resolve_explicit_voice_task_assignees(project, draft)
if explicit_assignees is not None:
return explicit_assignees
if transcript_requests_all_project_assignees(transcript, draft):
return [
serialize_resolved_assignee(project_member.member, 1.0, "all_project_members")
@ -1081,8 +1110,14 @@ def resolve_voice_task_state(project, draft, allow_default=True):
if not project:
return None
state_hint = draft.get("state_hint")
states = list(State.objects.filter(project=project).order_by("sequence"))
explicit_state_id = normalize_uuid_string(draft.get("state_id"))
if explicit_state_id:
explicit_state = next((state for state in states if str(state.id) == explicit_state_id), None)
if explicit_state:
return serialize_resolved_state(explicit_state, 1.0, "explicit_state_id")
state_hint = draft.get("state_hint")
if state_hint:
best_state = None
best_score = 0.0
@ -1452,9 +1487,22 @@ def find_latest_voice_task_issue(memory_sessions, project_id=None):
def resolve_voice_task_memory_target(workspace, user, draft, current_session=None, client_context=None, transcript=None):
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
explicit_target_task_id = normalize_uuid_string(draft.get("target_task_id"))
memory_sessions = get_committed_voice_task_memory_sessions(workspace, user, current_session=current_session)
generic_memory_reference = transcript_has_generic_memory_reference(transcript)
if explicit_target_task_id:
target_issue = (
Issue.issue_objects.filter(workspace=workspace, id=explicit_target_task_id)
.select_related("project")
.first()
)
if (
is_voice_task_issue_available(target_issue)
and get_accessible_projects(workspace, user).filter(id=target_issue.project_id).exists()
):
return target_issue, "explicit_target_task_id", None
if target_memory_ref:
target_uuid = None
try:
@ -1523,6 +1571,8 @@ def voice_task_has_update_fields(draft, resolution):
or draft.get("due_time")
or (draft.get("priority") and draft.get("priority") != "none")
or draft.get("checklist")
or "assignee_ids" in draft
or draft.get("state_id")
or (resolution.get("assignee") and resolution["assignee"]["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD)
or (draft.get("state_hint") and resolution.get("state") and resolution["state"]["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD)
or resolution.get("labels")
@ -1846,7 +1896,13 @@ def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=N
if draft.get("priority") and draft["priority"] != "none":
payload["priority"] = draft["priority"]
if assignees:
if "assignee_ids" in draft:
payload["assignee_ids"] = [
assignee["id"]
for assignee in assignees
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
]
elif assignees:
payload["assignee_ids"] = [
assignee["id"]
for assignee in assignees
@ -1855,7 +1911,7 @@ def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=N
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
payload["assignee_ids"] = [assignee["id"]]
if (draft.get("state_hint") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:
if (draft.get("state_hint") or draft.get("state_id") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:
payload["state_id"] = state["id"]
if labels:
@ -2043,6 +2099,29 @@ def normalize_string(value, max_length=None):
return normalized[:max_length] if max_length else normalized
def normalize_uuid_string(value):
normalized = normalize_string(value, 80)
if not normalized:
return None
try:
return str(uuid.UUID(normalized))
except (TypeError, ValueError):
return None
def normalize_uuid_list(value, limit=50):
if not isinstance(value, list):
return []
result = []
for item in value[:limit]:
normalized = normalize_uuid_string(item)
if normalized and normalized not in result:
result.append(normalized)
return result
def normalize_string_list(value, limit=20, item_max_length=120):
if not isinstance(value, list):
return []
@ -2097,6 +2176,9 @@ def normalize_voice_task_parse(parsed):
normalized = {
"intent": intent,
"target_memory_ref": normalize_string(parsed.get("target_memory_ref"), 80),
"project_id": normalize_uuid_string(parsed.get("project_id")),
"state_id": normalize_uuid_string(parsed.get("state_id")),
"target_task_id": normalize_uuid_string(parsed.get("target_task_id") or parsed.get("target_issue_id")),
"project_hint": normalize_string(parsed.get("project_hint"), 255),
"state_hint": normalize_string(parsed.get("state_hint"), 120),
"assignee_hint": normalize_string(parsed.get("assignee_hint"), 255),
@ -2115,6 +2197,8 @@ def normalize_voice_task_parse(parsed):
},
"questions": normalize_string_list(parsed.get("questions"), limit=10, item_max_length=255),
}
if "assignee_ids" in parsed:
normalized["assignee_ids"] = normalize_uuid_list(parsed.get("assignee_ids"))
return normalized

View File

@ -238,19 +238,19 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
multiple={multiple}
>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<Combobox.Options className="fixed z-[1000]" static>
<div
className="my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200 focus:outline-none"
className="nodedc-dropdown-surface my-1 w-56 focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2">
<div className="nodedc-dropdown-search">
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("search")}
@ -268,9 +268,9 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none ${
active ? "bg-layer-transparent-hover" : ""
} ${selected ? "text-primary" : "text-secondary"}`
`nodedc-dropdown-option cursor-pointer ${active ? "bg-white/[0.06]" : ""} ${
selected ? "text-primary" : "text-secondary"
}`
}
>
{({ selected }) => (
@ -283,10 +283,10 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
);
})
) : (
<p className="px-1.5 py-1 text-placeholder italic">{t("no_matching_results")}</p>
<p className="px-3 py-2 text-12 text-placeholder italic">{t("no_matching_results")}</p>
)
) : (
<p className="px-1.5 py-1 text-placeholder italic">{t("loading")}</p>
<p className="px-3 py-2 text-12 text-placeholder italic">{t("loading")}</p>
)}
</div>
</div>

View File

@ -5,22 +5,57 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ElementType, ReactNode } from "react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { CheckCircle2, Mic, Pencil, Plus, RotateCcw, Square, Trash2, Upload, X } from "lucide-react";
import {
CalendarDays,
CheckCircle2,
Clock3,
FileText,
Flag,
FolderKanban,
ListChecks,
Mic,
Pencil,
RotateCcw,
Search,
Square,
Target,
Trash2,
Upload,
UserRound,
X,
} from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
import { Tooltip } from "@plane/propel/tooltip";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EIssuesStoreType } from "@plane/types";
import type { TVoiceTaskCommitResult, TVoiceTaskUploadResult } from "@plane/types";
import type {
ISearchIssueResponse,
TIssuePriorities,
TVoiceTaskCommitResult,
TVoiceTaskDraft,
TVoiceTaskPriority,
TVoiceTaskUploadResult,
} from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { cn } from "@plane/utils";
import { cn, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
import { useIssues } from "@/hooks/store/use-issues";
import useDebounce from "@/hooks/use-debounce";
// services
import { ProjectService } from "@/services/project";
import { WorkspaceAIService } from "@/services/workspace-ai.service";
const workspaceAIService = new WorkspaceAIService();
const projectService = new ProjectService();
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "committing" | "committed" | "error";
@ -31,6 +66,20 @@ const UNAVAILABLE_LABELS = {
role_denied: "Voice Task недоступен для вашей роли",
} as const;
const PRIORITY_LABELS: Record<Exclude<TVoiceTaskPriority, null>, string> = {
none: "Без приоритета",
low: "Низкий",
medium: "Средний",
high: "Высокий",
urgent: "Срочный",
};
const voiceTaskPropertyButtonClassName =
"nodedc-work-item-property-button !h-8 !min-h-8 !w-full !justify-start !px-3 text-12";
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"));
function getSupportedMimeType() {
if (typeof MediaRecorder === "undefined") return "";
@ -62,19 +111,18 @@ function getRouteParam(value: string | string[] | undefined) {
}
function getCommitButtonLabel(intent?: string) {
if (intent === "update_task") return "Применить изменения";
if (intent === "delete_task") return "Удалить задачу";
return "Создать задачу";
if (intent === "delete_task") return "Опубликовать удаление";
return "Опубликовать задачу";
}
function getCommitStatusLabel(status: TVoiceTaskerStatus) {
if (status === "committed") return "Committed";
if (status === "success") return "Draft parsed";
if (status === "committing") return "Committing";
if (status === "uploading") return "Processing";
if (status === "recording") return "Recording";
if (status === "error") return "Error";
return "Ready";
if (status === "committed") return "Опубликовано";
if (status === "success") return "Черновик готов";
if (status === "committing") return "Публикуем";
if (status === "uploading") return "Распознаем";
if (status === "recording") return "Идет запись";
if (status === "error") return "Ошибка";
return "Готово к записи";
}
function getCommitSuccessTitle(result: TVoiceTaskCommitResult) {
@ -115,6 +163,304 @@ function getVoiceTaskAssigneesLabel(result: TVoiceTaskUploadResult) {
return result.draft?.assignee_hint || "не распознано";
}
function getVoiceTaskAssigneeIds(result: TVoiceTaskUploadResult | null) {
const resolution = result?.resolution as TVoiceTaskResolutionWithAssignees | undefined;
const resolvedAssignees = resolution?.assignees?.length
? resolution.assignees
: resolution?.assignee
? [resolution.assignee]
: [];
return resolvedAssignees.flatMap((assignee) => (assignee?.id ? [assignee.id] : []));
}
function getPriorityLabel(priority: TVoiceTaskPriority) {
return priority ? PRIORITY_LABELS[priority] : "Не распознано";
}
function parseVoiceTaskTime(value?: string | null) {
const match = value?.match(/^(\d{2}):(\d{2})/);
if (!match) return { hour: null, minute: null };
const hour = VOICE_TASK_TIME_HOURS.includes(match[1]) ? match[1] : null;
const minute = VOICE_TASK_TIME_MINUTES.includes(match[2]) ? match[2] : null;
return { hour, minute };
}
type TVoiceTaskTargetOption = {
id: string;
title: string;
projectId: string;
projectIdentifier: string;
sequenceId: number;
typeId?: string | null;
};
function getTargetOptionFromResolution(result: TVoiceTaskUploadResult | null): TVoiceTaskTargetOption | null {
const target = result?.resolution?.target_task;
if (!target) return null;
return {
id: target.id,
title: target.title,
projectId: target.project_id,
projectIdentifier: target.project_identifier,
sequenceId: target.sequence_id,
};
}
function getTargetOptionFromSearchIssue(issue: ISearchIssueResponse): TVoiceTaskTargetOption {
return {
id: issue.id,
title: issue.name,
projectId: issue.project_id,
projectIdentifier: issue.project__identifier,
sequenceId: issue.sequence_id,
typeId: issue.type_id,
};
}
function VoiceTaskPropertyBlock({
children,
className,
icon: Icon,
label,
}: {
children: ReactNode;
className?: string;
icon: ElementType;
label: string;
}) {
return (
<div
className={cn(
"relative z-0 rounded-[18px] bg-white/[0.035] p-2.5 backdrop-blur-xl transition focus-within:z-[850] hover:z-20",
className
)}
>
<div className="mb-1.5 flex items-center gap-1.5 text-10 font-semibold tracking-[0.18em] text-tertiary uppercase">
<Icon className="size-3 text-[rgb(var(--nodedc-accent-rgb))]" />
{label}
</div>
{children}
</div>
);
}
function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | null) => void; value?: string | null }) {
const [isOpen, setIsOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const selectedTime = parseVoiceTaskTime(value);
const selectedHour = selectedTime.hour ?? "00";
const selectedMinute = selectedTime.minute ?? "00";
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setIsOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const updateTime = (hour: string, minute: string) => onChange(`${hour}:${minute}`);
return (
<div ref={rootRef} className="relative h-full">
<button
type="button"
className={cn(
voiceTaskPropertyButtonClassName,
"flex items-center justify-between gap-2 rounded-full text-left outline-none",
isOpen && "bg-white/[0.06]"
)}
onClick={() => setIsOpen((current) => !current)}
>
<span className={cn("truncate", value ? "text-primary" : "text-tertiary")}>{value || "--:--"}</span>
<Clock3 className="size-3.5 flex-shrink-0 text-secondary" />
</button>
{isOpen && (
<div className="nodedc-dropdown-surface absolute top-full right-0 left-0 z-[900] mt-2 !rounded-[22px] !p-2">
<div className="grid max-h-56 grid-cols-2 gap-2 overflow-hidden">
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{VOICE_TASK_TIME_HOURS.map((hour) => (
<button
key={hour}
type="button"
className={cn(
"mx-auto flex size-8 items-center justify-center rounded-full text-12 font-semibold text-secondary transition outline-none hover:bg-white/[0.07] hover:text-primary",
selectedHour === hour &&
"bg-[rgb(var(--nodedc-accent-rgb))] text-black hover:bg-[rgb(var(--nodedc-accent-rgb))] hover:text-black"
)}
onClick={() => updateTime(hour, selectedMinute)}
>
{hour}
</button>
))}
</div>
<div className="max-h-56 space-y-1 overflow-y-auto pl-1">
{VOICE_TASK_TIME_MINUTES.map((minute) => (
<button
key={minute}
type="button"
className={cn(
"mx-auto flex size-8 items-center justify-center rounded-full text-12 font-semibold text-secondary transition outline-none hover:bg-white/[0.07] hover:text-primary",
selectedMinute === minute &&
"bg-[rgb(var(--nodedc-accent-rgb))] text-black hover:bg-[rgb(var(--nodedc-accent-rgb))] hover:text-black"
)}
onClick={() => updateTime(selectedHour, minute)}
>
{minute}
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}
function VoiceTaskTargetPicker({
disabled = false,
onChange,
projectId,
selectedIssue,
workspaceSlug,
}: {
disabled?: boolean;
onChange: (issue: TVoiceTaskTargetOption) => void;
projectId: string | null;
selectedIssue: TVoiceTaskTargetOption | null;
workspaceSlug: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isLoading, setIsLoading] = useState(false);
const debouncedSearchTerm = useDebounce(searchTerm, 350);
useEffect(() => {
if (!isOpen || !projectId) return;
let isMounted = true;
setIsLoading(true);
projectService
.projectIssuesSearch(workspaceSlug, projectId, {
search: debouncedSearchTerm,
workspace_search: false,
})
.then((response) => {
if (isMounted) setIssues(response);
})
.catch(() => {
if (isMounted) setIssues([]);
})
.finally(() => {
if (isMounted) setIsLoading(false);
});
return () => {
isMounted = false;
};
}, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]);
return (
<div className="relative">
<button
type="button"
className={cn(
"flex h-10 w-full items-center justify-between gap-3 rounded-full bg-white/[0.045] px-4 text-left text-13 text-primary transition outline-none hover:bg-white/[0.07]",
{
"cursor-not-allowed text-tertiary": disabled || !projectId,
}
)}
disabled={disabled || !projectId}
onClick={() => setIsOpen((current) => !current)}
>
<span className="min-w-0 flex-1 truncate">
{selectedIssue ? (
<span className="flex min-w-0 items-center gap-2">
<IssueIdentifier
projectId={selectedIssue.projectId}
projectIdentifier={selectedIssue.projectIdentifier}
issueSequenceId={selectedIssue.sequenceId}
issueTypeId={selectedIssue.typeId}
size="xs"
variant="secondary"
/>
<span className="truncate">{selectedIssue.title}</span>
</span>
) : projectId ? (
"Выбрать задачу"
) : (
"Сначала выберите проект"
)}
</span>
<Search className="size-4 flex-shrink-0 text-tertiary" />
</button>
{isOpen && projectId && (
<div className="nodedc-dropdown-surface absolute top-full right-0 left-0 z-[760] mt-2 p-3">
<div className="nodedc-dropdown-search">
<Search className="size-3.5 text-placeholder" />
<input
autoFocus
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Найти задачу"
/>
</div>
<div className="mt-2 max-h-72 space-y-1 overflow-y-auto">
{isLoading ? (
<div className="px-3 py-2 text-12 text-tertiary">Загрузка задач...</div>
) : issues.length ? (
issues.map((issue) => (
<button
key={issue.id}
type="button"
className="nodedc-dropdown-option w-full text-left"
onClick={() => {
onChange(getTargetOptionFromSearchIssue(issue));
setIsOpen(false);
setSearchTerm("");
}}
>
<span className="flex min-w-0 flex-1 items-center gap-2 truncate">
<IssueIdentifier
projectId={issue.project_id}
projectIdentifier={issue.project__identifier}
issueSequenceId={issue.sequence_id}
issueTypeId={issue.type_id}
size="xs"
variant="secondary"
/>
<span className="truncate">{issue.name}</span>
</span>
</button>
))
) : (
<div className="px-3 py-2 text-12 text-tertiary">Задачи не найдены</div>
)}
</div>
</div>
)}
</div>
);
}
type Props = {
workspaceSlug: string;
};
@ -142,6 +488,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const [error, setError] = useState<string | null>(null);
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
@ -199,6 +546,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setError(null);
setParseResult(null);
setCommitResult(null);
setSelectedTargetIssue(null);
setStatus("idle");
}, [stopRecording]);
@ -308,6 +656,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
try {
const result = await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData);
setParseResult(result);
setSelectedTargetIssue(getTargetOptionFromResolution(result));
setStatus("success");
setToast({
type: TOAST_TYPE.SUCCESS,
@ -396,6 +745,40 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
}
};
const updateDraft = useCallback((patch: Partial<TVoiceTaskDraft>) => {
setParseResult((current) => {
if (!current?.draft) return current;
return {
...current,
draft: {
...current.draft,
...patch,
},
};
});
setCommitResult(null);
}, []);
const draft = parseResult?.draft;
const selectedProjectId = draft?.project_id ?? parseResult?.resolution?.project?.id ?? null;
const selectedStateId = draft?.state_id ?? parseResult?.resolution?.state?.id ?? null;
const selectedAssigneeIds = draft?.assignee_ids ?? getVoiceTaskAssigneeIds(parseResult);
const selectedTargetTask = selectedTargetIssue ?? getTargetOptionFromResolution(parseResult);
const selectedTargetTaskId = draft?.target_task_id ?? selectedTargetTask?.id ?? null;
const selectedPriority = (draft?.priority ?? "none") as TIssuePriorities;
const warnings = parseResult ? getVoiceTaskWarnings(parseResult) : [];
const canPublishDraft = Boolean(
parseResult?.voice_session_id &&
draft &&
draft.intent !== "unknown" &&
!isUploading &&
!isCommitting &&
!commitResult?.task_id &&
(draft.intent === "create_task"
? selectedProjectId && draft.title?.trim()
: selectedTargetTaskId && (draft.intent === "delete_task" || selectedProjectId))
);
return (
<>
<div className="pointer-events-none fixed right-4 bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)] z-[29]">
@ -416,214 +799,336 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
</Tooltip>
</div>
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.MD}>
<div className="px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-18 font-medium text-primary">Voice Task</h3>
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
</div>
<button
type="button"
className="flex size-8 items-center justify-center rounded-md text-tertiary hover:bg-layer-2 hover:text-primary"
onClick={handleClose}
>
<X className="size-4" />
</button>
</div>
<div className="mt-5 rounded-lg border-[0.5px] border-subtle bg-layer-1 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
<ModalCore
isOpen={isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={draft ? EModalWidth.VIIXL : EModalWidth.MD}
className="overflow-visible"
>
<div className={cn("p-5", draft && "sm:p-6")}>
{!draft ? (
<>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-18 font-medium text-primary">Voice Tasker</h3>
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
</div>
<button
type="button"
className="flex size-9 items-center justify-center rounded-full bg-white/[0.045] text-secondary transition outline-none hover:bg-white/[0.075] hover:text-primary"
onClick={handleClose}
>
<X className="size-4" />
</button>
</div>
<div
className={cn(
"flex size-14 items-center justify-center rounded-full",
isRecording ? "bg-red-500/15 text-red-500" : "bg-pink-500/10 text-pink-500"
)}
>
<Mic className={cn("size-6", { "animate-pulse": isRecording })} />
</div>
</div>
{audioUrl && !isRecording && (
<audio controls src={audioUrl} className="mt-4 w-full">
<track kind="captions" />
</audio>
)}
{error && (
<div className="border-red-500/30 bg-red-500/10 text-red-500 mt-4 rounded-md border-[0.5px] px-3 py-2 text-12">
{error}
</div>
)}
{parseResult?.draft && (
<div className="mt-4 space-y-3 rounded-md border-[0.5px] border-subtle bg-layer-2 p-3">
<div className="flex items-center gap-2 text-13 font-medium text-primary">
<CheckCircle2 className="text-green-500 size-4" />
Draft готов
<div className="mt-5 rounded-[24px] bg-white/[0.035] p-4 backdrop-blur-xl">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
</div>
<div
className={cn(
"flex size-14 items-center justify-center rounded-full",
isRecording
? "bg-red-500/15 text-red-500"
: "bg-white/[0.06] text-[rgb(var(--nodedc-accent-rgb))]"
)}
>
<Mic className={cn("size-6", { "animate-pulse": isRecording })} />
</div>
</div>
{parseResult.transcript && (
<div>
<div className="text-11 font-medium text-tertiary uppercase">Транскрипт</div>
<p className="mt-1 text-12 leading-5 break-words whitespace-pre-wrap text-secondary">
{parseResult.transcript}
</p>
</div>
{audioUrl && !isRecording && (
<audio controls src={audioUrl} className="mt-4 w-full">
<track kind="captions" />
</audio>
)}
<div className="grid grid-cols-1 gap-2 text-12 sm:grid-cols-2">
<div>
<div className="text-11 font-medium text-tertiary uppercase">Название</div>
<div className="mt-0.5 text-primary">{parseResult.draft.title || "не распознано"}</div>
{error && (
<div className="border-red-500/25 bg-red-500/10 text-red-500 mt-4 rounded-[18px] border px-3 py-2 text-12">
{error}
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Intent</div>
<div className="mt-0.5 text-primary">{parseResult.draft.intent}</div>
</div>
{parseResult.resolution?.target_task && (
<div className="sm:col-span-2">
<div className="text-11 font-medium text-tertiary uppercase">Целевая задача</div>
<div className="mt-0.5 text-primary">
{[parseResult.resolution.target_task.key, parseResult.resolution.target_task.title]
.filter(Boolean)
.join(" · ")}
)}
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
{audioBlob && !isRecording && (
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
<RotateCcw className="mr-2 size-4" />
Перезаписать
</Button>
)}
<Button
variant={isRecording ? "error-fill" : "secondary"}
size="lg"
onClick={isRecording ? stopRecording : startRecording}
disabled={isUploading || isCommitting}
>
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
{isRecording ? "Стоп" : "Записать"}
</Button>
<Button
variant="primary"
size="lg"
onClick={uploadAudio}
loading={isUploading}
disabled={!audioBlob || isRecording || isCommitting}
>
<Upload className="mr-2 size-4" />
Сформировать карточку
</Button>
</div>
</>
) : (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-22 font-semibold text-primary">Предпросмотр задачи</h3>
</div>
<button
type="button"
className="flex size-10 items-center justify-center rounded-full bg-white/[0.045] text-secondary transition outline-none hover:bg-white/[0.075] hover:text-primary"
onClick={handleClose}
>
<X className="size-5" />
</button>
</div>
<div className="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="space-y-4">
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">Исходные данные</div>
<div className="rounded-[28px] bg-white/[0.04] p-4 backdrop-blur-xl">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
</div>
<div className="flex size-12 items-center justify-center rounded-full bg-black/45 text-[rgb(var(--nodedc-accent-rgb))]">
<Mic className="size-5" />
</div>
</div>
)}
{parseResult.resolution?.project_change && (
<div className="sm:col-span-2">
<div className="text-11 font-medium text-tertiary uppercase">Перенос проекта</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution.project_change.from.name} -&gt;{" "}
{parseResult.resolution.project_change.to.name}
{audioUrl && !isRecording && (
<audio controls src={audioUrl} className="mt-4 w-full">
<track kind="captions" />
</audio>
)}
</div>
{parseResult?.transcript && (
<div className="rounded-[28px] bg-white/[0.035] p-4 backdrop-blur-xl">
<div className="mb-2 flex items-center gap-2 text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
<FileText className="size-3.5 text-[rgb(var(--nodedc-accent-rgb))]" />
Транскрипт
</div>
<p className="max-h-64 overflow-y-auto text-13 leading-6 break-words whitespace-pre-wrap text-secondary">
{parseResult.transcript}
</p>
</div>
)}
<div>
<div className="text-11 font-medium text-tertiary uppercase">Проект</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution?.project?.name || parseResult.draft.project_hint || "не распознано"}
<div className="rounded-[28px] bg-white/[0.035] p-4 backdrop-blur-xl">
<div className="mb-3 flex items-center gap-2 text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
<ListChecks className="size-3.5 text-[rgb(var(--nodedc-accent-rgb))]" />
Диагностика
</div>
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Статус</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution?.state?.name || parseResult.draft.state_hint || "не распознано"}
<div className="flex flex-wrap gap-2 text-11 text-secondary">
<span className="rounded-full bg-white/[0.05] px-3 py-1">
intent {formatConfidence(draft.confidence.intent)}
</span>
<span className="rounded-full bg-white/[0.05] px-3 py-1">
project {formatConfidence(draft.confidence.project)}
</span>
<span className="rounded-full bg-white/[0.05] px-3 py-1">
assignee {formatConfidence(draft.confidence.assignee)}
</span>
<span className="rounded-full bg-white/[0.05] px-3 py-1">
task {formatConfidence(draft.confidence.task)}
</span>
</div>
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Исполнитель</div>
<div className="mt-0.5 text-primary">{getVoiceTaskAssigneesLabel(parseResult)}</div>
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Срок</div>
<div className="mt-0.5 text-primary">
{[parseResult.draft.due_date, parseResult.draft.due_time].filter(Boolean).join(" ") ||
"не распознано"}
</div>
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Приоритет</div>
<div className="mt-0.5 text-primary">{parseResult.draft.priority || "не распознано"}</div>
</div>
</div>
{parseResult.draft.description && (
<div>
<div className="text-11 font-medium text-tertiary uppercase">Описание</div>
<p className="mt-1 text-12 leading-5 break-words whitespace-pre-wrap text-secondary">
{parseResult.draft.description}
</p>
<div className="space-y-4">
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
Сформированная карточка
</div>
)}
<div className="flex flex-wrap gap-1.5 text-11 text-secondary">
<span className="rounded bg-layer-1 px-2 py-1">
intent {formatConfidence(parseResult.draft.confidence.intent)}
</span>
<span className="rounded bg-layer-1 px-2 py-1">
project {formatConfidence(parseResult.draft.confidence.project)}
</span>
<span className="rounded bg-layer-1 px-2 py-1">
assignee {formatConfidence(parseResult.draft.confidence.assignee)}
</span>
<span className="rounded bg-layer-1 px-2 py-1">
task {formatConfidence(parseResult.draft.confidence.task)}
</span>
{parseResult.resolution?.project && (
<span className="rounded bg-layer-1 px-2 py-1">
resolved project {formatConfidence(parseResult.resolution.project.confidence)}
</span>
<div className="rounded-[28px] bg-white/[0.04] p-4 backdrop-blur-xl">
<label className="block text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
Название
</label>
<input
className="mt-2 w-full rounded-[20px] border-0 bg-white/[0.045] px-4 py-3 text-18 font-semibold text-primary outline-none placeholder:text-tertiary"
value={draft.title ?? ""}
onChange={(event) => updateDraft({ title: event.target.value || null })}
placeholder="Название задачи"
/>
<label className="mt-4 block text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
Описание
</label>
<textarea
className="mt-2 min-h-32 w-full resize-none rounded-[22px] border-0 bg-white/[0.045] px-4 py-3 text-13 leading-6 text-primary outline-none placeholder:text-tertiary"
value={draft.description ?? ""}
onChange={(event) => updateDraft({ description: event.target.value || null })}
placeholder="Подробная постановка"
/>
</div>
{draft.intent !== "create_task" && (
<VoiceTaskPropertyBlock icon={Target} label="Целевая задача">
<VoiceTaskTargetPicker
workspaceSlug={workspaceSlug}
projectId={selectedProjectId}
selectedIssue={selectedTargetTask}
onChange={(issue) => {
setSelectedTargetIssue(issue);
updateDraft({
target_task_id: issue.id,
target_memory_ref: issue.id,
project_id: issue.projectId,
});
}}
/>
</VoiceTaskPropertyBlock>
)}
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
<VoiceTaskPropertyBlock icon={FolderKanban} label="Проект">
<div className="h-8">
<ProjectDropdown
multiple={false}
value={selectedProjectId}
onChange={(projectId) => {
updateDraft({
project_id: projectId,
state_id: null,
target_task_id: null,
target_memory_ref: null,
assignee_ids: [],
});
setSelectedTargetIssue(null);
}}
buttonVariant="border-with-text"
buttonClassName={voiceTaskPropertyButtonClassName}
placeholder="Выбрать проект"
/>
</div>
</VoiceTaskPropertyBlock>
<VoiceTaskPropertyBlock icon={Flag} label="Приоритет">
<div className="h-8">
<PriorityDropdown
value={selectedPriority}
onChange={(priority) => updateDraft({ priority: priority as TVoiceTaskPriority })}
buttonVariant="border-with-text"
buttonClassName={voiceTaskPropertyButtonClassName}
placeholder={getPriorityLabel(draft.priority)}
/>
</div>
</VoiceTaskPropertyBlock>
<VoiceTaskPropertyBlock icon={CheckCircle2} label="Статус">
<div className="h-8">
<StateDropdown
value={selectedStateId}
onChange={(stateId) => updateDraft({ state_id: stateId })}
projectId={selectedProjectId ?? undefined}
buttonVariant="border-with-text"
buttonClassName={voiceTaskPropertyButtonClassName}
disabled={!selectedProjectId}
isForWorkItemCreation={draft.intent === "create_task"}
/>
</div>
</VoiceTaskPropertyBlock>
<VoiceTaskPropertyBlock icon={UserRound} label="Исполнители">
<div className="h-8">
<MemberDropdown
projectId={selectedProjectId ?? undefined}
value={selectedAssigneeIds}
onChange={(assigneeIds) => updateDraft({ assignee_ids: assigneeIds })}
buttonVariant="border-with-text"
buttonClassName={voiceTaskPropertyButtonClassName}
placeholder={parseResult ? getVoiceTaskAssigneesLabel(parseResult) : "Выбрать исполнителей"}
multiple
/>
</div>
</VoiceTaskPropertyBlock>
<VoiceTaskPropertyBlock icon={CalendarDays} label="Срок">
<div className="h-8">
<DateDropdown
value={draft.due_date}
onChange={(date) =>
updateDraft({ due_date: date ? (renderFormattedPayloadDate(date) ?? null) : null })
}
buttonVariant="border-with-text"
buttonClassName={voiceTaskPropertyButtonClassName}
placeholder="Дата"
/>
</div>
</VoiceTaskPropertyBlock>
<VoiceTaskPropertyBlock icon={Clock3} label="Время">
<div className="h-8">
<VoiceTaskTimePicker
value={draft.due_time}
onChange={(dueTime) => updateDraft({ due_time: dueTime })}
/>
</div>
</VoiceTaskPropertyBlock>
</div>
{warnings.length > 0 && (
<div className="border-yellow-500/25 bg-yellow-500/10 text-yellow-200 rounded-[22px] border px-4 py-3 text-12">
{warnings.join(" · ")}
</div>
)}
{error && (
<div className="border-red-500/25 bg-red-500/10 text-red-500 rounded-[22px] border px-4 py-3 text-12">
{error}
</div>
)}
{commitResult?.task_key && (
<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>
)}
</div>
</div>
{Boolean(getVoiceTaskWarnings(parseResult).length) && (
<div className="border-yellow-500/30 bg-yellow-500/10 text-yellow-600 rounded border-[0.5px] px-3 py-2 text-11">
{getVoiceTaskWarnings(parseResult).join(" · ")}
</div>
)}
{commitResult?.task_key && (
<div className="border-green-500/30 bg-green-500/10 text-green-600 rounded border-[0.5px] px-3 py-2 text-12">
{getCommitSuccessMessage(commitResult)}
</div>
<div className="mt-6 flex flex-wrap justify-end gap-2">
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
<Mic className="mr-2 size-4" />
Перезаписать
</Button>
{!commitResult?.task_id && (
<Button
variant={draft.intent === "delete_task" ? "error-fill" : "primary"}
size="lg"
onClick={commitVoiceTask}
loading={isCommitting}
disabled={!canPublishDraft}
>
{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)}
</Button>
)}
</div>
)}
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
{audioBlob && !isRecording && (
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
<RotateCcw className="mr-2 size-4" />
Перезаписать
</Button>
)}
<Button
variant={isRecording ? "error-fill" : "secondary"}
size="lg"
onClick={isRecording ? stopRecording : startRecording}
disabled={isUploading || isCommitting}
>
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
{isRecording ? "Стоп" : "Записать"}
</Button>
{!parseResult?.draft && (
<Button
variant="primary"
size="lg"
onClick={uploadAudio}
loading={isUploading}
disabled={!audioBlob || isRecording || isCommitting}
>
<Upload className="mr-2 size-4" />
Отправить
</Button>
)}
{parseResult?.draft?.intent && parseResult.draft.intent !== "unknown" && !commitResult?.task_id && (
<Button
variant={parseResult.draft.intent === "delete_task" ? "error-fill" : "primary"}
size="lg"
onClick={commitVoiceTask}
loading={isCommitting}
disabled={!parseResult.voice_session_id || !parseResult.resolution?.can_commit || isUploading}
>
{parseResult.draft.intent === "update_task" ? (
<Pencil className="mr-2 size-4" />
) : parseResult.draft.intent === "delete_task" ? (
<Trash2 className="mr-2 size-4" />
) : (
<Plus className="mr-2 size-4" />
)}
{getCommitButtonLabel(parseResult.draft.intent)}
</Button>
)}
</div>
</>
)}
</div>
</ModalCore>
</>

View File

@ -83,6 +83,9 @@ export type TVoiceTaskDraft = {
intent: TVoiceTaskIntent;
target_memory_ref: string | null;
project_id?: string | null;
state_id?: string | null;
assignee_ids?: string[];
target_task_id?: string | null;
project_hint: string | null;
state_hint: string | null;
assignee_hint: string | null;