ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: resolver, commit и обновление доски Voice Tasker

This commit is contained in:
DCCONSTRUCTIONS
2026-04-24 18:50:07 +03:00
parent 1a20e19a93
commit d3b47326da
7 changed files with 685 additions and 17 deletions
@@ -5,21 +5,24 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { CheckCircle2, Mic, RotateCcw, Square, Upload, X } from "lucide-react";
import { CheckCircle2, Mic, Plus, RotateCcw, Square, Upload, 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 type { TVoiceTaskUploadResult } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import type { TVoiceTaskCommitResult, TVoiceTaskUploadResult } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { cn } from "@plane/utils";
import { useIssues } from "@/hooks/store/use-issues";
// services
import { WorkspaceAIService } from "@/services/workspace-ai.service";
const workspaceAIService = new WorkspaceAIService();
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "error";
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "committing" | "committed" | "error";
const UNAVAILABLE_LABELS = {
disabled: "AI-функции не активированы для этого workspace",
@@ -47,11 +50,36 @@ function formatConfidence(value?: number) {
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
}
function getCurrentProjectId() {
if (typeof window === "undefined") return null;
const match = window.location.pathname.match(/\/projects\/([^/]+)/);
return match?.[1] ?? null;
}
function getRouteParam(value: string | string[] | undefined) {
if (Array.isArray(value)) return value[0];
return value?.toString();
}
type Props = {
workspaceSlug: string;
};
export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const params = useParams();
const activeProjectId = getRouteParam(params.projectId);
const activeProjectViewId = getRouteParam(params.viewId);
const activeGlobalViewId = getRouteParam(params.globalViewId);
const {
issues: { fetchIssuesWithExistingPagination: refreshProjectIssues },
} = useIssues(EIssuesStoreType.PROJECT);
const {
issues: { fetchIssuesWithExistingPagination: refreshProjectViewIssues },
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
const {
issues: { fetchIssuesWithExistingPagination: refreshGlobalIssues },
} = useIssues(EIssuesStoreType.GLOBAL);
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<TVoiceTaskerStatus>("idle");
const [duration, setDuration] = useState(0);
@@ -59,6 +87,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
@@ -76,6 +105,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const isAvailable = !!preflight?.available;
const isRecording = status === "recording";
const isUploading = status === "uploading";
const isCommitting = status === "committing";
const tooltipContent = useMemo(() => {
if (!preflight) return "Voice Task";
@@ -114,6 +144,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setDuration(0);
setError(null);
setParseResult(null);
setCommitResult(null);
setStatus("idle");
}, [stopRecording]);
@@ -209,6 +240,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
"client_context",
JSON.stringify({
current_page: window.location.pathname,
current_project_id: getCurrentProjectId(),
locale: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
@@ -235,6 +267,67 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
}
};
const refreshVisibleIssueStores = useCallback(
async (createdProjectId?: string) => {
const refreshes: Promise<unknown>[] = [];
if (createdProjectId && activeProjectId === createdProjectId) {
refreshes.push(refreshProjectIssues(workspaceSlug, activeProjectId, "mutation"));
if (activeProjectViewId) {
refreshes.push(refreshProjectViewIssues(workspaceSlug, activeProjectId, activeProjectViewId, "mutation"));
}
}
if (activeGlobalViewId) {
refreshes.push(refreshGlobalIssues(workspaceSlug, activeGlobalViewId, "mutation"));
}
if (!refreshes.length) return;
await Promise.allSettled(refreshes);
},
[
activeGlobalViewId,
activeProjectId,
activeProjectViewId,
refreshGlobalIssues,
refreshProjectIssues,
refreshProjectViewIssues,
workspaceSlug,
]
);
const commitVoiceTask = async () => {
if (!parseResult?.voice_session_id || !parseResult.draft) return;
setStatus("committing");
setError(null);
try {
const result = await workspaceAIService.commitVoiceTask(workspaceSlug, {
voice_session_id: parseResult.voice_session_id,
action: "create_task",
draft: parseResult.draft,
});
await refreshVisibleIssueStores(result.project_id);
setCommitResult(result);
setStatus("committed");
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Задача создана",
message: result.task_key ? `Создана ${result.task_key}` : "Work item создан.",
});
} catch (err) {
const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось создать задачу.";
setError(message);
setStatus("error");
setToast({
type: TOAST_TYPE.ERROR,
title: "Задача не создана",
message,
});
}
};
return (
<>
<div className="pointer-events-none fixed right-4 z-[29] bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)]">
@@ -276,7 +369,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<div>
<div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary">
{status === "success" ? "Draft parsed" : isUploading ? "Processing" : isRecording ? "Recording" : "Ready"}
{status === "committed"
? "Created"
: status === "success"
? "Draft parsed"
: isCommitting
? "Creating"
: isUploading
? "Processing"
: isRecording
? "Recording"
: "Ready"}
</div>
</div>
<div
@@ -328,11 +431,15 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Проект</div>
<div className="mt-0.5 text-primary">{parseResult.draft.project_hint || "не распознано"}</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="mt-0.5 text-primary">{parseResult.draft.assignee_hint || "не распознано"}</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>
@@ -360,6 +467,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<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>
{Boolean(parseResult.warnings?.length || parseResult.draft.questions.length) && (
@@ -367,13 +479,19 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{[...(parseResult.warnings ?? []), ...parseResult.draft.questions].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>
)}
</div>
)}
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
{audioBlob && !isRecording && (
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading}>
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
<RotateCcw className="mr-2 size-4" />
Перезаписать
</Button>
@@ -382,15 +500,33 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
variant={isRecording ? "error-fill" : "secondary"}
size="lg"
onClick={isRecording ? stopRecording : startRecording}
disabled={isUploading}
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}>
<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 && (
<Button
variant="primary"
size="lg"
onClick={commitVoiceTask}
loading={isCommitting}
disabled={!parseResult.voice_session_id || !parseResult.resolution?.can_commit || isUploading}
>
<Plus className="mr-2 size-4" />
Создать задачу
</Button>
)}
</div>
</div>
</ModalCore>
@@ -6,7 +6,9 @@
import { API_BASE_URL } from "@plane/constants";
import type {
TVoiceTaskCommitResult,
TVoiceTaskPreflight,
TVoiceTaskDraft,
TVoiceTaskUploadResult,
TWorkspaceAIConnectionTestResult,
TWorkspaceAISettings,
@@ -61,4 +63,19 @@ export class WorkspaceAIService extends APIService {
throw error?.response?.data;
});
}
async commitVoiceTask(
workspaceSlug: string,
data: {
voice_session_id: string;
action: "create_task";
draft?: TVoiceTaskDraft;
}
): Promise<TVoiceTaskCommitResult> {
return this.post(`/api/workspaces/${workspaceSlug}/voice-task/commit/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}