ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: интерактивный предпросмотр Voice Tasker
This commit is contained in:
parent
a13ff3b954
commit
323b4b964e
|
|
@ -691,6 +691,8 @@ def harden_voice_task_intent(parsed, transcript):
|
||||||
|
|
||||||
if parsed.get("intent") != "update_task":
|
if parsed.get("intent") != "update_task":
|
||||||
return parsed
|
return parsed
|
||||||
|
if parsed.get("target_task_id"):
|
||||||
|
return parsed
|
||||||
|
|
||||||
target_memory_ref = normalize_string(parsed.get("target_memory_ref"), 80)
|
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(
|
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):
|
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)
|
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
|
||||||
return bool(
|
return bool(
|
||||||
parse_issue_key_reference(target_memory_ref)
|
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")
|
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):
|
def transcript_requests_all_project_assignees(transcript, draft=None):
|
||||||
normalized = normalize_match_value(transcript)
|
normalized = normalize_match_value(transcript)
|
||||||
assignee_hint = normalize_match_value((draft or {}).get("assignee_hint"))
|
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):
|
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):
|
if transcript_requests_all_project_assignees(transcript, draft):
|
||||||
return [
|
return [
|
||||||
serialize_resolved_assignee(project_member.member, 1.0, "all_project_members")
|
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:
|
if not project:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
state_hint = draft.get("state_hint")
|
|
||||||
states = list(State.objects.filter(project=project).order_by("sequence"))
|
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:
|
if state_hint:
|
||||||
best_state = None
|
best_state = None
|
||||||
best_score = 0.0
|
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):
|
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)
|
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)
|
memory_sessions = get_committed_voice_task_memory_sessions(workspace, user, current_session=current_session)
|
||||||
generic_memory_reference = transcript_has_generic_memory_reference(transcript)
|
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:
|
if target_memory_ref:
|
||||||
target_uuid = None
|
target_uuid = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -1523,6 +1571,8 @@ def voice_task_has_update_fields(draft, resolution):
|
||||||
or draft.get("due_time")
|
or draft.get("due_time")
|
||||||
or (draft.get("priority") and draft.get("priority") != "none")
|
or (draft.get("priority") and draft.get("priority") != "none")
|
||||||
or draft.get("checklist")
|
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 (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 (draft.get("state_hint") and resolution.get("state") and resolution["state"]["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD)
|
||||||
or resolution.get("labels")
|
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":
|
if draft.get("priority") and draft["priority"] != "none":
|
||||||
payload["priority"] = draft["priority"]
|
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"] = [
|
payload["assignee_ids"] = [
|
||||||
assignee["id"]
|
assignee["id"]
|
||||||
for assignee in assignees
|
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:
|
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||||
payload["assignee_ids"] = [assignee["id"]]
|
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"]
|
payload["state_id"] = state["id"]
|
||||||
|
|
||||||
if labels:
|
if labels:
|
||||||
|
|
@ -2043,6 +2099,29 @@ def normalize_string(value, max_length=None):
|
||||||
return normalized[:max_length] if max_length else normalized
|
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):
|
def normalize_string_list(value, limit=20, item_max_length=120):
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return []
|
return []
|
||||||
|
|
@ -2097,6 +2176,9 @@ def normalize_voice_task_parse(parsed):
|
||||||
normalized = {
|
normalized = {
|
||||||
"intent": intent,
|
"intent": intent,
|
||||||
"target_memory_ref": normalize_string(parsed.get("target_memory_ref"), 80),
|
"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),
|
"project_hint": normalize_string(parsed.get("project_hint"), 255),
|
||||||
"state_hint": normalize_string(parsed.get("state_hint"), 120),
|
"state_hint": normalize_string(parsed.get("state_hint"), 120),
|
||||||
"assignee_hint": normalize_string(parsed.get("assignee_hint"), 255),
|
"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),
|
"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
|
return normalized
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,19 +238,19 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
>
|
>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<Combobox.Options className="fixed z-10" static>
|
<Combobox.Options className="fixed z-[1000]" static>
|
||||||
<div
|
<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}
|
ref={setPopperElement}
|
||||||
style={styles.popper}
|
style={styles.popper}
|
||||||
{...attributes.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} />
|
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
|
||||||
<Combobox.Input
|
<Combobox.Input
|
||||||
as="input"
|
as="input"
|
||||||
ref={inputRef}
|
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}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder={t("search")}
|
placeholder={t("search")}
|
||||||
|
|
@ -268,9 +268,9 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
className={({ active, selected }) =>
|
className={({ active, selected }) =>
|
||||||
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none ${
|
`nodedc-dropdown-option cursor-pointer ${active ? "bg-white/[0.06]" : ""} ${
|
||||||
active ? "bg-layer-transparent-hover" : ""
|
selected ? "text-primary" : "text-secondary"
|
||||||
} ${selected ? "text-primary" : "text-secondary"}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ selected }) => (
|
{({ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,57 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ElementType, ReactNode } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import useSWR from "swr";
|
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
|
// plane imports
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
import { Tooltip } from "@plane/propel/tooltip";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import { EIssuesStoreType } from "@plane/types";
|
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 { 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 { useIssues } from "@/hooks/store/use-issues";
|
||||||
|
import useDebounce from "@/hooks/use-debounce";
|
||||||
// services
|
// services
|
||||||
|
import { ProjectService } from "@/services/project";
|
||||||
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
||||||
|
|
||||||
const workspaceAIService = new WorkspaceAIService();
|
const workspaceAIService = new WorkspaceAIService();
|
||||||
|
const projectService = new ProjectService();
|
||||||
|
|
||||||
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "committing" | "committed" | "error";
|
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "committing" | "committed" | "error";
|
||||||
|
|
||||||
|
|
@ -31,6 +66,20 @@ const UNAVAILABLE_LABELS = {
|
||||||
role_denied: "Voice Task недоступен для вашей роли",
|
role_denied: "Voice Task недоступен для вашей роли",
|
||||||
} as const;
|
} 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() {
|
function getSupportedMimeType() {
|
||||||
if (typeof MediaRecorder === "undefined") return "";
|
if (typeof MediaRecorder === "undefined") return "";
|
||||||
|
|
||||||
|
|
@ -62,19 +111,18 @@ function getRouteParam(value: string | string[] | undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommitButtonLabel(intent?: string) {
|
function getCommitButtonLabel(intent?: string) {
|
||||||
if (intent === "update_task") return "Применить изменения";
|
if (intent === "delete_task") return "Опубликовать удаление";
|
||||||
if (intent === "delete_task") return "Удалить задачу";
|
return "Опубликовать задачу";
|
||||||
return "Создать задачу";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommitStatusLabel(status: TVoiceTaskerStatus) {
|
function getCommitStatusLabel(status: TVoiceTaskerStatus) {
|
||||||
if (status === "committed") return "Committed";
|
if (status === "committed") return "Опубликовано";
|
||||||
if (status === "success") return "Draft parsed";
|
if (status === "success") return "Черновик готов";
|
||||||
if (status === "committing") return "Committing";
|
if (status === "committing") return "Публикуем";
|
||||||
if (status === "uploading") return "Processing";
|
if (status === "uploading") return "Распознаем";
|
||||||
if (status === "recording") return "Recording";
|
if (status === "recording") return "Идет запись";
|
||||||
if (status === "error") return "Error";
|
if (status === "error") return "Ошибка";
|
||||||
return "Ready";
|
return "Готово к записи";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommitSuccessTitle(result: TVoiceTaskCommitResult) {
|
function getCommitSuccessTitle(result: TVoiceTaskCommitResult) {
|
||||||
|
|
@ -115,6 +163,304 @@ function getVoiceTaskAssigneesLabel(result: TVoiceTaskUploadResult) {
|
||||||
return result.draft?.assignee_hint || "не распознано";
|
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 = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
};
|
};
|
||||||
|
|
@ -142,6 +488,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
|
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
|
||||||
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
|
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
|
||||||
|
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
|
||||||
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
@ -199,6 +546,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setParseResult(null);
|
setParseResult(null);
|
||||||
setCommitResult(null);
|
setCommitResult(null);
|
||||||
|
setSelectedTargetIssue(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}, [stopRecording]);
|
}, [stopRecording]);
|
||||||
|
|
||||||
|
|
@ -308,6 +656,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
try {
|
try {
|
||||||
const result = await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData);
|
const result = await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData);
|
||||||
setParseResult(result);
|
setParseResult(result);
|
||||||
|
setSelectedTargetIssue(getTargetOptionFromResolution(result));
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="pointer-events-none fixed right-4 bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)] z-[29]">
|
<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>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.MD}>
|
<ModalCore
|
||||||
<div className="px-5 py-4">
|
isOpen={isOpen}
|
||||||
<div className="flex items-start justify-between gap-4">
|
handleClose={handleClose}
|
||||||
<div>
|
position={EModalPosition.CENTER}
|
||||||
<h3 className="text-18 font-medium text-primary">Voice Task</h3>
|
width={draft ? EModalWidth.VIIXL : EModalWidth.MD}
|
||||||
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
|
className="overflow-visible"
|
||||||
</div>
|
>
|
||||||
<button
|
<div className={cn("p-5", draft && "sm:p-6")}>
|
||||||
type="button"
|
{!draft ? (
|
||||||
className="flex size-8 items-center justify-center rounded-md text-tertiary hover:bg-layer-2 hover:text-primary"
|
<>
|
||||||
onClick={handleClose}
|
<div className="flex items-start justify-between gap-4">
|
||||||
>
|
<div>
|
||||||
<X className="size-4" />
|
<h3 className="text-18 font-medium text-primary">Voice Tasker</h3>
|
||||||
</button>
|
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<div className="mt-5 rounded-lg border-[0.5px] border-subtle bg-layer-1 p-4">
|
type="button"
|
||||||
<div className="flex items-center justify-between gap-4">
|
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"
|
||||||
<div>
|
onClick={handleClose}
|
||||||
<div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div>
|
>
|
||||||
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</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 && (
|
<div className="mt-5 rounded-[24px] bg-white/[0.035] p-4 backdrop-blur-xl">
|
||||||
<audio controls src={audioUrl} className="mt-4 w-full">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<track kind="captions" />
|
<div>
|
||||||
</audio>
|
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
|
||||||
)}
|
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
|
||||||
|
</div>
|
||||||
{error && (
|
<div
|
||||||
<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">
|
className={cn(
|
||||||
{error}
|
"flex size-14 items-center justify-center rounded-full",
|
||||||
</div>
|
isRecording
|
||||||
)}
|
? "bg-red-500/15 text-red-500"
|
||||||
|
: "bg-white/[0.06] text-[rgb(var(--nodedc-accent-rgb))]"
|
||||||
{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">
|
<Mic className={cn("size-6", { "animate-pulse": isRecording })} />
|
||||||
<CheckCircle2 className="text-green-500 size-4" />
|
</div>
|
||||||
Draft готов
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{parseResult.transcript && (
|
{audioUrl && !isRecording && (
|
||||||
<div>
|
<audio controls src={audioUrl} className="mt-4 w-full">
|
||||||
<div className="text-11 font-medium text-tertiary uppercase">Транскрипт</div>
|
<track kind="captions" />
|
||||||
<p className="mt-1 text-12 leading-5 break-words whitespace-pre-wrap text-secondary">
|
</audio>
|
||||||
{parseResult.transcript}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 text-12 sm:grid-cols-2">
|
{error && (
|
||||||
<div>
|
<div className="border-red-500/25 bg-red-500/10 text-red-500 mt-4 rounded-[18px] border px-3 py-2 text-12">
|
||||||
<div className="text-11 font-medium text-tertiary uppercase">Название</div>
|
{error}
|
||||||
<div className="mt-0.5 text-primary">{parseResult.draft.title || "не распознано"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<div className="text-11 font-medium text-tertiary uppercase">Intent</div>
|
</div>
|
||||||
<div className="mt-0.5 text-primary">{parseResult.draft.intent}</div>
|
|
||||||
</div>
|
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||||
{parseResult.resolution?.target_task && (
|
{audioBlob && !isRecording && (
|
||||||
<div className="sm:col-span-2">
|
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
|
||||||
<div className="text-11 font-medium text-tertiary uppercase">Целевая задача</div>
|
<RotateCcw className="mr-2 size-4" />
|
||||||
<div className="mt-0.5 text-primary">
|
Перезаписать
|
||||||
{[parseResult.resolution.target_task.key, parseResult.resolution.target_task.title]
|
</Button>
|
||||||
.filter(Boolean)
|
)}
|
||||||
.join(" · ")}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{audioUrl && !isRecording && (
|
||||||
{parseResult.resolution?.project_change && (
|
<audio controls src={audioUrl} className="mt-4 w-full">
|
||||||
<div className="sm:col-span-2">
|
<track kind="captions" />
|
||||||
<div className="text-11 font-medium text-tertiary uppercase">Перенос проекта</div>
|
</audio>
|
||||||
<div className="mt-0.5 text-primary">
|
)}
|
||||||
{parseResult.resolution.project_change.from.name} ->{" "}
|
</div>
|
||||||
{parseResult.resolution.project_change.to.name}
|
|
||||||
|
{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>
|
</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>
|
|
||||||
<div className="text-11 font-medium text-tertiary uppercase">Проект</div>
|
<div className="rounded-[28px] bg-white/[0.035] p-4 backdrop-blur-xl">
|
||||||
<div className="mt-0.5 text-primary">
|
<div className="mb-3 flex items-center gap-2 text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||||
{parseResult.resolution?.project?.name || parseResult.draft.project_hint || "не распознано"}
|
<ListChecks className="size-3.5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||||
|
Диагностика
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-2 text-11 text-secondary">
|
||||||
<div>
|
<span className="rounded-full bg-white/[0.05] px-3 py-1">
|
||||||
<div className="text-11 font-medium text-tertiary uppercase">Статус</div>
|
intent {formatConfidence(draft.confidence.intent)}
|
||||||
<div className="mt-0.5 text-primary">
|
</span>
|
||||||
{parseResult.resolution?.state?.name || parseResult.draft.state_hint || "не распознано"}
|
<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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{parseResult.draft.description && (
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||||
<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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5 text-11 text-secondary">
|
<div className="rounded-[28px] bg-white/[0.04] p-4 backdrop-blur-xl">
|
||||||
<span className="rounded bg-layer-1 px-2 py-1">
|
<label className="block text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||||
intent {formatConfidence(parseResult.draft.confidence.intent)}
|
Название
|
||||||
</span>
|
</label>
|
||||||
<span className="rounded bg-layer-1 px-2 py-1">
|
<input
|
||||||
project {formatConfidence(parseResult.draft.confidence.project)}
|
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"
|
||||||
</span>
|
value={draft.title ?? ""}
|
||||||
<span className="rounded bg-layer-1 px-2 py-1">
|
onChange={(event) => updateDraft({ title: event.target.value || null })}
|
||||||
assignee {formatConfidence(parseResult.draft.confidence.assignee)}
|
placeholder="Название задачи"
|
||||||
</span>
|
/>
|
||||||
<span className="rounded bg-layer-1 px-2 py-1">
|
<label className="mt-4 block text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||||
task {formatConfidence(parseResult.draft.confidence.task)}
|
Описание
|
||||||
</span>
|
</label>
|
||||||
{parseResult.resolution?.project && (
|
<textarea
|
||||||
<span className="rounded bg-layer-1 px-2 py-1">
|
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"
|
||||||
resolved project {formatConfidence(parseResult.resolution.project.confidence)}
|
value={draft.description ?? ""}
|
||||||
</span>
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{Boolean(getVoiceTaskWarnings(parseResult).length) && (
|
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
||||||
<div className="border-yellow-500/30 bg-yellow-500/10 text-yellow-600 rounded border-[0.5px] px-3 py-2 text-11">
|
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
|
||||||
{getVoiceTaskWarnings(parseResult).join(" · ")}
|
<Mic className="mr-2 size-4" />
|
||||||
</div>
|
Перезаписать
|
||||||
)}
|
</Button>
|
||||||
|
{!commitResult?.task_id && (
|
||||||
{commitResult?.task_key && (
|
<Button
|
||||||
<div className="border-green-500/30 bg-green-500/10 text-green-600 rounded border-[0.5px] px-3 py-2 text-12">
|
variant={draft.intent === "delete_task" ? "error-fill" : "primary"}
|
||||||
{getCommitSuccessMessage(commitResult)}
|
size="lg"
|
||||||
</div>
|
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>
|
)}
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</ModalCore>
|
</ModalCore>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ export type TVoiceTaskDraft = {
|
||||||
intent: TVoiceTaskIntent;
|
intent: TVoiceTaskIntent;
|
||||||
target_memory_ref: string | null;
|
target_memory_ref: string | null;
|
||||||
project_id?: string | null;
|
project_id?: string | null;
|
||||||
|
state_id?: string | null;
|
||||||
|
assignee_ids?: string[];
|
||||||
|
target_task_id?: string | null;
|
||||||
project_hint: string | null;
|
project_hint: string | null;
|
||||||
state_hint: string | null;
|
state_hint: string | null;
|
||||||
assignee_hint: string | null;
|
assignee_hint: string | null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue