ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: resolver, commit и обновление доски Voice Tasker
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user