ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hardening Voice Tasker routing, сроков и transcript
This commit is contained in:
@@ -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} ->{" "}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user