ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hardening Voice Tasker routing, сроков и transcript

This commit is contained in:
DCCONSTRUCTIONS
2026-04-24 21:54:34 +03:00
parent d3b47326da
commit 597480adb9
9 changed files with 1629 additions and 227 deletions
@@ -12,7 +12,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useOutsideClickDetector } from "@plane/hooks";
// plane helpers
import { MoreHorizontal } from "lucide-react";
import { Mic, MoreHorizontal } from "lucide-react";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
@@ -99,6 +99,7 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
// derived values
const subIssueCount = issue?.sub_issues_count ?? 0;
const isVoiceTask = issue?.external_source === "voice_tasker";
const handleEventPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -151,6 +152,13 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
</div>
</Tooltip>
{isVoiceTask && (
<div className="inline-flex max-w-full items-center gap-1 rounded border border-pink-500/30 bg-pink-500/10 px-1.5 py-0.5 text-11 font-medium text-pink-300">
<Mic className="h-3 w-3 shrink-0" />
<span>Voice</span>
</div>
)}
<IssueProperties
className="flex flex-wrap items-center gap-2 pt-1.5 whitespace-nowrap text-tertiary"
issue={issue}
@@ -7,7 +7,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { CheckCircle2, Mic, Plus, RotateCcw, Square, Upload, X } from "lucide-react";
import { CheckCircle2, Mic, Pencil, Plus, RotateCcw, Square, Trash2, Upload, X } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
import { Tooltip } from "@plane/propel/tooltip";
@@ -61,6 +61,43 @@ function getRouteParam(value: string | string[] | undefined) {
return value?.toString();
}
function getCommitButtonLabel(intent?: string) {
if (intent === "update_task") 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";
}
function getCommitSuccessTitle(result: TVoiceTaskCommitResult) {
if (result.status === "updated") return "Задача обновлена";
if (result.status === "deleted") return "Задача удалена";
return "Задача создана";
}
function getCommitSuccessMessage(result: TVoiceTaskCommitResult) {
if (result.task_key && result.status === "updated") return `Обновлена ${result.task_key}`;
if (result.task_key && result.status === "deleted") return `Удалена ${result.task_key}`;
if (result.task_key) return `Создана ${result.task_key}`;
if (result.status === "updated") return "Work item обновлен.";
if (result.status === "deleted") return "Work item удален.";
return "Work item создан.";
}
function getVoiceTaskWarnings(result: TVoiceTaskUploadResult) {
return Array.from(
new Set([...(result.warnings ?? []), ...(result.resolution?.warnings ?? []), ...(result.draft?.questions ?? [])])
);
}
type Props = {
workspaceSlug: string;
};
@@ -174,7 +211,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
}, [audioBlob]);
const startRecording = async () => {
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === "undefined"
) {
setError("Браузер не поддерживает запись аудио.");
setStatus("error");
return;
@@ -230,6 +271,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setStatus("uploading");
setError(null);
setParseResult(null);
setCommitResult(null);
const audioType = audioBlob.type || "audio/webm";
const extension = audioType.includes("mp4") ? "m4a" : "webm";
@@ -256,7 +298,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
message: "Transcript и draft получены.",
});
} catch (err) {
const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио.";
const message =
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио.";
setError(message);
setStatus("error");
setToast({
@@ -299,13 +342,23 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const commitVoiceTask = async () => {
if (!parseResult?.voice_session_id || !parseResult.draft) return;
const action = parseResult.draft.intent;
if (action === "unknown") return;
if (action === "delete_task") {
const targetTitle =
parseResult.resolution?.target_task?.key || parseResult.resolution?.target_task?.title || "последнюю задачу";
const confirmed = window.confirm(`Удалить ${targetTitle}?`);
if (!confirmed) return;
}
setStatus("committing");
setError(null);
try {
const result = await workspaceAIService.commitVoiceTask(workspaceSlug, {
voice_session_id: parseResult.voice_session_id,
action: "create_task",
action,
draft: parseResult.draft,
});
await refreshVisibleIssueStores(result.project_id);
@@ -313,16 +366,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setStatus("committed");
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Задача создана",
message: result.task_key ? `Создана ${result.task_key}` : "Work item создан.",
title: getCommitSuccessTitle(result),
message: getCommitSuccessMessage(result),
});
} catch (err) {
const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось создать задачу.";
const message =
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task.";
setError(message);
setStatus("error");
setToast({
type: TOAST_TYPE.ERROR,
title: "Задача не создана",
title: "Voice Task не применен",
message,
});
}
@@ -330,14 +384,14 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
return (
<>
<div className="pointer-events-none fixed right-4 z-[29] bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)]">
<div className="pointer-events-none fixed right-4 bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)] z-[29]">
<Tooltip tooltipContent={tooltipContent} position="left">
<button
type="button"
className={cn(
"pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] shadow-lg transition",
"shadow-lg pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] transition",
isAvailable
? "border-pink-500/40 bg-pink-500 text-white hover:bg-pink-600"
? "border-pink-500/40 bg-pink-500 hover:bg-pink-600 text-white"
: "cursor-not-allowed border-subtle bg-layer-2 text-tertiary"
)}
disabled={!isAvailable}
@@ -368,19 +422,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<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">
{status === "committed"
? "Created"
: status === "success"
? "Draft parsed"
: isCommitting
? "Creating"
: isUploading
? "Processing"
: isRecording
? "Recording"
: "Ready"}
</div>
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
</div>
<div
className={cn(
@@ -399,7 +441,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)}
{error && (
<div className="mt-4 rounded-md border-[0.5px] border-red-500/30 bg-red-500/10 px-3 py-2 text-12 text-red-500">
<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>
)}
@@ -407,13 +449,13 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{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="size-4 text-green-500" />
<CheckCircle2 className="text-green-500 size-4" />
Draft готов
</div>
{parseResult.transcript && (
<div>
<div className="text-11 font-medium uppercase text-tertiary">Транскрипт</div>
<div className="text-11 font-medium text-tertiary uppercase">Транскрипт</div>
<p className="mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary">
{parseResult.transcript}
</p>
@@ -422,40 +464,66 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<div className="grid grid-cols-1 gap-2 text-12 sm:grid-cols-2">
<div>
<div className="text-11 font-medium uppercase text-tertiary">Название</div>
<div className="text-11 font-medium text-tertiary uppercase">Название</div>
<div className="mt-0.5 text-primary">{parseResult.draft.title || "не распознано"}</div>
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Intent</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>
)}
{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}
</div>
</div>
)}
<div>
<div className="text-11 font-medium uppercase text-tertiary">Проект</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>
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Исполнитель</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>
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Исполнитель</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution?.assignee?.name || parseResult.draft.assignee_hint || "не распознано"}
</div>
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Срок</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(" ") || "не распознано"}
{[parseResult.draft.due_date, parseResult.draft.due_time].filter(Boolean).join(" ") ||
"не распознано"}
</div>
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Приоритет</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 uppercase text-tertiary">Описание</div>
<div className="text-11 font-medium text-tertiary uppercase">Описание</div>
<p className="mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary">
{parseResult.draft.description}
</p>
@@ -463,10 +531,18 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)}
<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>
<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)}
@@ -474,15 +550,15 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)}
</div>
{Boolean(parseResult.warnings?.length || parseResult.draft.questions.length) && (
<div className="rounded border-[0.5px] border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-11 text-yellow-600">
{[...(parseResult.warnings ?? []), ...parseResult.draft.questions].join(" · ")}
{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="rounded border-[0.5px] border-green-500/30 bg-green-500/10 px-3 py-2 text-12 text-green-600">
Создана задача {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>
@@ -505,26 +581,34 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{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>
{parseResult?.draft?.intent === "create_task" && !commitResult?.task_id && (
{!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}
>
<Plus className="mr-2 size-4" />
Создать задачу
{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>