diff --git a/docs_prod/2_voicetasker/VOICETASKER_TECH.md b/docs_prod/2_voicetasker/VOICETASKER_TECH.md index 8a31359..b335505 100644 --- a/docs_prod/2_voicetasker/VOICETASKER_TECH.md +++ b/docs_prod/2_voicetasker/VOICETASKER_TECH.md @@ -293,7 +293,9 @@ idle - обычная кнопка микрофона recording - идет запись uploading - отправка аудио processing - транскрибация и разбор -success - задача создана / обновлена +success - draft разобран +committing - создание задачи +committed - задача создана / обновлена error - ошибка ``` @@ -318,6 +320,8 @@ error - ошибка - `Редактировать`; - `Отмена`. +После успешного commit frontend обязан выполнить точечный mutation-refresh активного issue-store, если пользователь находится на проектной доске, project view или global view, куда может попасть созданная задача. Это не polling и не reload страницы: обновляется только уже открытый список/доска через существующую Plane store-модель. + Auto-create допустим только если: ```txt @@ -485,6 +489,30 @@ Response: }, "questions": [] }, + "resolution": { + "project": { + "id": "project_uuid", + "name": "Бухгалтерия", + "identifier": "BUH", + "confidence": 0.91, + "source": "project_hint" + }, + "assignee": { + "id": "user_uuid", + "name": "Настя", + "email": "nastya@example.com", + "confidence": 0.84, + "source": "assignee_hint" + }, + "labels": [ + { + "id": "label_uuid", + "name": "voice" + } + ], + "warnings": [], + "can_commit": true + }, "warnings": [], "requires_confirmation": true, "models": { @@ -539,12 +567,38 @@ Response: ```json { + "ok": true, "status": "created", + "voice_session_id": "uuid", "task_id": "task_uuid", - "task_url": "/nodedc/projects/.../work-items/..." + "task_key": "BUH-128", + "task_url": "/nodedc/browse/BUH-128/", + "project_id": "project_uuid", + "sequence_id": 128, + "resolution": { + "project": { + "id": "project_uuid", + "name": "Бухгалтерия", + "identifier": "BUH", + "confidence": 0.91, + "source": "project_hint" + }, + "assignee": { + "id": "user_uuid", + "name": "Настя", + "email": "nastya@example.com", + "confidence": 0.84, + "source": "assignee_hint" + }, + "labels": [{ "id": "label_uuid", "name": "voice" }], + "warnings": [], + "can_commit": true + } } ``` +На Stage 4 commit поддерживает только `create_task`. `update_task` и `delete_task` остаются в следующих этапах, потому что требуют voice memory и отдельного confirmation policy. + --- ## 8. Database @@ -943,6 +997,7 @@ voice_task.error - commit endpoint; - создание `Issue` через внутренний Plane layer; - activity log/model activity как у обычного work item; +- точечное обновление активного issue-store после commit без reload/polling; - `voice_task_memory` для created action. ### Stage 5 - Memory commands @@ -967,6 +1022,7 @@ voice_task.error - monthly budget/soft cap; - multi-provider AI; - streaming/realtime voice; +- realtime task event stream для ситуационных панелей без reload/polling; - audio debug retention для dev/staging; - автоматическое создание label `voice` / `needs-assignee-review` по настройке. @@ -986,8 +1042,9 @@ voice_task.error 10. Assignee resolver назначает только уверенно найденного project member. 11. Если assignee не найден - задача может быть создана без assignee. 12. Commit создает обычную `Issue` через внутренний Plane backend layer. -13. `due_date` маппится в `target_date`. -14. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP. +13. После commit активная доска/список обновляется без reload страницы. +14. `due_date` маппится в `target_date`. +15. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP. 15. Voice session сохраняется. 16. Последняя voice-задача сохраняется в memory. 17. Update last task работает минимум для `target_date` и description. diff --git a/plane-src/apps/api/plane/app/urls/voice_tasker.py b/plane-src/apps/api/plane/app/urls/voice_tasker.py index f62dd25..39c38e2 100644 --- a/plane-src/apps/api/plane/app/urls/voice_tasker.py +++ b/plane-src/apps/api/plane/app/urls/voice_tasker.py @@ -5,6 +5,7 @@ from django.urls import path from plane.app.views import ( + VoiceTaskCommitEndpoint, VoiceTaskParseEndpoint, VoiceTaskPreflightEndpoint, WorkspaceAISettingsEndpoint, @@ -33,4 +34,9 @@ urlpatterns = [ VoiceTaskParseEndpoint.as_view(), name="voice-task-parse", ), + path( + "workspaces//voice-task/commit/", + VoiceTaskCommitEndpoint.as_view(), + name="voice-task-commit", + ), ] diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 9b20662..e4bb24c 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -244,6 +244,7 @@ from .webhook.base import ( ) from .voice_tasker import ( + VoiceTaskCommitEndpoint, VoiceTaskParseEndpoint, VoiceTaskPreflightEndpoint, WorkspaceAISettingsEndpoint, diff --git a/plane-src/apps/api/plane/app/views/voice_tasker.py b/plane-src/apps/api/plane/app/views/voice_tasker.py index 59bd07b..c5a5fea 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -4,8 +4,11 @@ import json import re +from difflib import SequenceMatcher +from html import escape from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone from openai import OpenAI @@ -15,9 +18,15 @@ from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from plane.app.permissions import ROLE, allow_permission -from plane.app.serializers import WorkspaceAISettingsSerializer +from plane.app.serializers import IssueCreateSerializer, WorkspaceAISettingsSerializer +from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.issue_description_version_task import issue_description_version_task +from plane.bgtasks.webhook_task import model_activity from plane.db.models import ( + Issue, + Label, Project, + ProjectMember, VoiceTaskSession, Workspace, WorkspaceAICredential, @@ -26,6 +35,7 @@ from plane.db.models import ( ) from plane.license.utils.encryption import decrypt_data from plane.utils.exception_logger import log_exception +from plane.utils.host import base_host from .base import BaseAPIView @@ -34,6 +44,8 @@ VOICE_TASK_INTENTS = {"create_task", "update_task", "delete_task", "unknown"} VOICE_TASK_PRIORITIES = {"none", "low", "medium", "high", "urgent"} VOICE_TASK_MEMORY_LIMIT = 5 VOICE_TASK_CONTEXT_LIMIT = 100 +VOICE_TASK_PROJECT_MATCH_THRESHOLD = 0.8 +VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD = 0.8 DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") TIME_PATTERN = re.compile(r"^\d{2}:\d{2}$") @@ -242,13 +254,20 @@ def get_client_language(client_context): return language if len(language) == 2 else None -def serialize_workspace_projects(workspace, user): +def get_accessible_projects(workspace, user): workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first() projects = Project.objects.filter(workspace=workspace, archived_at__isnull=True) - if not workspace_member or workspace_member.role != ROLE.ADMIN.value: - projects = projects.filter(project_projectmember__member=user, project_projectmember__is_active=True) + if not workspace_member: + return Project.objects.none() + projects = projects.filter(project_projectmember__member=user, project_projectmember__is_active=True) + + return projects.distinct() + + +def serialize_workspace_projects(workspace, user): + projects = get_accessible_projects(workspace, user) return [ { "id": str(project.id), @@ -259,6 +278,242 @@ def serialize_workspace_projects(workspace, user): ] +def normalize_match_value(value): + normalized = normalize_string(value) + if not normalized: + return "" + + normalized = normalized.lower() + normalized = re.sub(r"\b(контур|проект|project|workspace|задача|таск)\b", " ", normalized) + normalized = re.sub(r"[^0-9a-zа-яё]+", " ", normalized) + return re.sub(r"\s+", " ", normalized).strip() + + +def get_text_match_score(query, candidates): + normalized_query = normalize_match_value(query) + if not normalized_query: + return 0.0 + + best_score = 0.0 + for candidate in candidates: + normalized_candidate = normalize_match_value(candidate) + if not normalized_candidate: + continue + if normalized_query == normalized_candidate: + best_score = max(best_score, 1.0) + elif normalized_query in normalized_candidate or normalized_candidate in normalized_query: + best_score = max(best_score, 0.9) + else: + best_score = max(best_score, SequenceMatcher(None, normalized_query, normalized_candidate).ratio()) + + return round(best_score, 3) + + +def serialize_resolved_project(project, confidence=0.0, source=None): + if not project: + return None + return { + "id": str(project.id), + "name": project.name, + "identifier": project.identifier, + "confidence": confidence, + "source": source, + } + + +def serialize_resolved_assignee(user, confidence=0.0, source=None): + if not user: + return None + return { + "id": str(user.id), + "name": user.display_name or user.email or "", + "email": user.email, + "confidence": confidence, + "source": source, + } + + +def resolve_voice_task_project(workspace, user, ai_settings, draft, client_context): + projects = list(get_accessible_projects(workspace, user).order_by("name")) + if not projects: + return None + + project_by_id = {str(project.id): project for project in projects} + explicit_project_id = normalize_string(draft.get("project_id")) + if explicit_project_id and explicit_project_id in project_by_id: + return serialize_resolved_project(project_by_id[explicit_project_id], 1.0, "explicit_project_id") + + project_hint = draft.get("project_hint") + if project_hint: + best_project = None + best_score = 0.0 + for project in projects: + score = get_text_match_score(project_hint, [project.name, project.identifier]) + if score > best_score: + best_project = project + best_score = score + if best_project and best_score >= VOICE_TASK_PROJECT_MATCH_THRESHOLD: + return serialize_resolved_project(best_project, best_score, "project_hint") + + current_project_id = normalize_string(client_context.get("current_project_id")) + if current_project_id and current_project_id in project_by_id: + return serialize_resolved_project(project_by_id[current_project_id], 0.7, "current_project") + + if ai_settings.default_project_id and str(ai_settings.default_project_id) in project_by_id: + return serialize_resolved_project(project_by_id[str(ai_settings.default_project_id)], 0.65, "default_project") + + if project_hint and best_project: + return serialize_resolved_project(best_project, best_score, "low_confidence_project_hint") + + return None + + +def resolve_voice_task_assignee(project, draft): + assignee_hint = draft.get("assignee_hint") + if not assignee_hint: + return None + + project_members = ( + ProjectMember.objects.filter(project=project, is_active=True, role__gte=ROLE.MEMBER.value, member__is_active=True) + .select_related("member") + .order_by("member__display_name", "member__email") + ) + best_member = None + best_score = 0.0 + for project_member in project_members: + member = project_member.member + score = get_text_match_score( + assignee_hint, + [ + member.display_name, + member.first_name, + member.last_name, + f"{member.first_name} {member.last_name}".strip(), + member.email, + ], + ) + if score > best_score: + best_member = member + best_score = score + + if not best_member: + return None + + return serialize_resolved_assignee(best_member, best_score, "assignee_hint") + + +def resolve_voice_task_labels(project, draft): + label_names = draft.get("labels") if isinstance(draft.get("labels"), list) else [] + if not label_names: + return [] + + labels = Label.objects.filter(project=project) + resolved_labels = [] + for label_name in label_names: + normalized_label_name = normalize_match_value(label_name) + if not normalized_label_name: + continue + label = next((label for label in labels if normalize_match_value(label.name) == normalized_label_name), None) + if label: + resolved_labels.append({"id": str(label.id), "name": label.name}) + return resolved_labels + + +def can_user_create_issue_in_project(user, workspace, project): + project_member = ProjectMember.objects.filter(project=project, member=user, is_active=True).first() + if not project_member: + return False + + if project_member.role in [ROLE.ADMIN.value, ROLE.MEMBER.value]: + return True + + return WorkspaceMember.objects.filter( + workspace=workspace, + member=user, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + + +def build_voice_task_resolution(workspace, user, ai_settings, draft, client_context): + warnings = [] + resolved_project = resolve_voice_task_project(workspace, user, ai_settings, draft, client_context) + project = Project.objects.filter(id=resolved_project["id"], workspace=workspace).first() if resolved_project else None + + if not project or not resolved_project: + warnings.append("project_not_resolved") + elif resolved_project["confidence"] < VOICE_TASK_PROJECT_MATCH_THRESHOLD and resolved_project["source"] == "low_confidence_project_hint": + warnings.append("low_project_confidence") + + resolved_assignee = None + resolved_labels = [] + if project: + resolved_assignee = resolve_voice_task_assignee(project, draft) + if resolved_assignee and resolved_assignee["confidence"] < VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD: + warnings.append("low_assignee_confidence") + resolved_labels = resolve_voice_task_labels(project, draft) + + if not can_user_create_issue_in_project(user, workspace, project): + warnings.append("project_permission_denied") + + if draft.get("intent") != "create_task": + warnings.append("unsupported_intent") + if not draft.get("title"): + warnings.append("missing_title") + + can_commit = bool( + project + and draft.get("intent") == "create_task" + and draft.get("title") + and "project_permission_denied" not in warnings + and "low_project_confidence" not in warnings + ) + + return { + "project": resolved_project, + "assignee": resolved_assignee, + "labels": resolved_labels, + "warnings": warnings, + "can_commit": can_commit, + } + + +def build_voice_task_description_html(draft): + parts = [] + if draft.get("description"): + parts.append(f"

{escape(draft['description'])}

") + + if draft.get("due_time"): + parts.append(f"

Ориентир по времени: до {escape(draft['due_time'])}

") + + checklist = draft.get("checklist") if isinstance(draft.get("checklist"), list) else [] + if checklist: + items = "".join(f"
  • {escape(item)}
  • " for item in checklist if item) + if items: + parts.append(f"

    Checklist:

    ") + + return "".join(parts) or "

    " + + +def build_voice_task_issue_payload(draft, resolution): + project = resolution.get("project") + assignee = resolution.get("assignee") + labels = resolution.get("labels") or [] + assignee_ids = [] + if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD: + assignee_ids = [assignee["id"]] + + return { + "name": draft["title"], + "description_html": build_voice_task_description_html(draft), + "target_date": draft.get("due_date"), + "priority": draft.get("priority") or "none", + "assignee_ids": assignee_ids, + "label_ids": [label["id"] for label in labels], + "project_id": project["id"] if project else None, + } + + def serialize_workspace_members(workspace): members = WorkspaceMember.objects.filter( workspace=workspace, @@ -641,6 +896,14 @@ class VoiceTaskParseEndpoint(BaseAPIView): model=ai_settings.structuring_model, ).parse(parser_context) warnings = get_voice_task_warnings(parsed, transcript) + resolution = build_voice_task_resolution( + workspace=workspace, + user=request.user, + ai_settings=ai_settings, + draft=parsed, + client_context=client_context, + ) + warnings = list(dict.fromkeys(warnings + resolution["warnings"])) requires_confirmation = voice_task_requires_confirmation(parsed, warnings) voice_session.status = VoiceTaskSession.Status.PARSED @@ -657,6 +920,7 @@ class VoiceTaskParseEndpoint(BaseAPIView): "transcript": transcript, "intent": parsed["intent"], "draft": parsed, + "resolution": resolution, "warnings": warnings, "requires_confirmation": requires_confirmation, "models": { @@ -691,3 +955,151 @@ class VoiceTaskParseEndpoint(BaseAPIView): }, status=pipeline_error.response_status, ) + + +class VoiceTaskCommitEndpoint(BaseAPIView): + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def post(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + preflight = get_voice_task_preflight(workspace, request.user) + if not preflight["available"]: + response_status = status.HTTP_403_FORBIDDEN if preflight["reason"] == "role_denied" else status.HTTP_400_BAD_REQUEST + return Response( + { + "ok": False, + "code": preflight["reason"], + "error": "Voice Tasker is not available for this workspace.", + }, + status=response_status, + ) + + voice_session_id = request.data.get("voice_session_id") + if not voice_session_id: + return Response( + {"ok": False, "code": "missing_voice_session_id", "error": "Voice session id is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + voice_session = VoiceTaskSession.objects.filter( + id=voice_session_id, + workspace=workspace, + user=request.user, + ).first() + if not voice_session: + return Response( + {"ok": False, "code": "voice_session_not_found", "error": "Voice session was not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if voice_session.status != VoiceTaskSession.Status.PARSED or not voice_session.parsed_json: + return Response( + {"ok": False, "code": "voice_session_not_parsed", "error": "Voice session is not ready to commit."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + draft = normalize_voice_task_parse(request.data.get("draft") or voice_session.parsed_json) + except VoiceTaskerPipelineError as exc: + return Response( + {"ok": False, "code": exc.code, "error": exc.message}, + status=exc.response_status, + ) + + action = request.data.get("action") or draft["intent"] + if action != "create_task" or draft["intent"] != "create_task": + return Response( + {"ok": False, "code": "unsupported_intent", "error": "Only create_task commit is supported now."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace) + resolution = build_voice_task_resolution( + workspace=workspace, + user=request.user, + ai_settings=ai_settings, + draft=draft, + client_context=voice_session.client_context or {}, + ) + if not resolution["can_commit"]: + return Response( + { + "ok": False, + "code": "draft_not_resolved", + "error": "Voice Task draft is not resolved enough to create a work item.", + "resolution": resolution, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace) + payload = build_voice_task_issue_payload(draft, resolution) + payload_without_project = {key: value for key, value in payload.items() if key != "project_id"} + + serializer = IssueCreateSerializer( + data=payload_without_project, + context={ + "project_id": project.id, + "workspace_id": workspace.id, + "default_assignee_id": project.default_assignee_id, + }, + ) + if not serializer.is_valid(): + return Response( + { + "ok": False, + "code": "issue_validation_failed", + "error": "Voice Task draft could not be converted to a work item.", + "details": serializer.errors, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue = serializer.save(created_by_id=request.user.id) + voice_session.created_task = issue + voice_session.parsed_json = draft + voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"]) + + requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder) + issue_activity.delay( + type="issue.activity.created", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project.id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + model_activity.delay( + model_name="issue", + model_id=str(issue.id), + requested_data=payload_without_project, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + issue_description_version_task.delay( + updated_issue=requested_data, + issue_id=str(issue.id), + user_id=request.user.id, + is_creating=True, + ) + + issue_key = f"{project.identifier}-{issue.sequence_id}" + task_url = f"/{slug}/browse/{issue_key}/" + return Response( + { + "ok": True, + "status": "created", + "voice_session_id": str(voice_session.id), + "task_id": str(issue.id), + "task_key": issue_key, + "task_url": task_url, + "project_id": str(project.id), + "sequence_id": issue.sequence_id, + "resolution": resolution, + }, + status=status.HTTP_201_CREATED, + ) diff --git a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx index 168749e..6f257ac 100644 --- a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx +++ b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx @@ -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("idle"); const [duration, setDuration] = useState(0); @@ -59,6 +87,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const [audioUrl, setAudioUrl] = useState(null); const [error, setError] = useState(null); const [parseResult, setParseResult] = useState(null); + const [commitResult, setCommitResult] = useState(null); const mediaRecorderRef = useRef(null); const streamRef = useRef(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[] = []; + + 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 ( <>
    @@ -276,7 +369,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
    {formatDuration(duration)}
    - {status === "success" ? "Draft parsed" : isUploading ? "Processing" : isRecording ? "Recording" : "Ready"} + {status === "committed" + ? "Created" + : status === "success" + ? "Draft parsed" + : isCommitting + ? "Creating" + : isUploading + ? "Processing" + : isRecording + ? "Recording" + : "Ready"}
    Проект
    -
    {parseResult.draft.project_hint || "не распознано"}
    +
    + {parseResult.resolution?.project?.name || parseResult.draft.project_hint || "не распознано"} +
    Исполнитель
    -
    {parseResult.draft.assignee_hint || "не распознано"}
    +
    + {parseResult.resolution?.assignee?.name || parseResult.draft.assignee_hint || "не распознано"} +
    Срок
    @@ -360,6 +467,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { project {formatConfidence(parseResult.draft.confidence.project)} assignee {formatConfidence(parseResult.draft.confidence.assignee)} task {formatConfidence(parseResult.draft.confidence.task)} + {parseResult.resolution?.project && ( + + resolved project {formatConfidence(parseResult.resolution.project.confidence)} + + )}
    {Boolean(parseResult.warnings?.length || parseResult.draft.questions.length) && ( @@ -367,13 +479,19 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { {[...(parseResult.warnings ?? []), ...parseResult.draft.questions].join(" · ")}
    )} + + {commitResult?.task_key && ( +
    + Создана задача {commitResult.task_key} +
    + )}
    )}
    {audioBlob && !isRecording && ( - @@ -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 ? : } {isRecording ? "Стоп" : "Записать"} - + {parseResult?.draft?.intent === "create_task" && !commitResult?.task_id && ( + + )}
    diff --git a/plane-src/apps/web/core/services/workspace-ai.service.ts b/plane-src/apps/web/core/services/workspace-ai.service.ts index de78767..35f2e93 100644 --- a/plane-src/apps/web/core/services/workspace-ai.service.ts +++ b/plane-src/apps/web/core/services/workspace-ai.service.ts @@ -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 { + return this.post(`/api/workspaces/${workspaceSlug}/voice-task/commit/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/plane-src/packages/types/src/ai.ts b/plane-src/packages/types/src/ai.ts index 7ff631d..5a8f643 100644 --- a/plane-src/packages/types/src/ai.ts +++ b/plane-src/packages/types/src/ai.ts @@ -82,6 +82,7 @@ export type TVoiceTaskPriority = "none" | "low" | "medium" | "high" | "urgent" | export type TVoiceTaskDraft = { intent: TVoiceTaskIntent; target_memory_ref: string | null; + project_id?: string | null; project_hint: string | null; assignee_hint: string | null; title: string | null; @@ -100,6 +101,29 @@ export type TVoiceTaskDraft = { questions: string[]; }; +export type TVoiceTaskResolution = { + project: { + id: string; + name: string; + identifier: string; + confidence: number; + source: string | null; + } | null; + assignee: { + id: string; + name: string; + email: string | null; + confidence: number; + source: string | null; + } | null; + labels: { + id: string; + name: string; + }[]; + warnings: string[]; + can_commit: boolean; +}; + export type TVoiceTaskUploadResult = { ok: boolean; status?: "uploaded" | "parsed"; @@ -108,6 +132,7 @@ export type TVoiceTaskUploadResult = { transcript?: string; intent?: TVoiceTaskIntent; draft?: TVoiceTaskDraft; + resolution?: TVoiceTaskResolution; warnings?: string[]; requires_confirmation?: boolean; models?: { @@ -123,3 +148,17 @@ export type TVoiceTaskUploadResult = { code?: string; error?: string; }; + +export type TVoiceTaskCommitResult = { + ok: boolean; + status?: "created"; + voice_session_id?: string; + task_id?: string; + task_key?: string; + task_url?: string; + project_id?: string; + sequence_id?: number; + resolution?: TVoiceTaskResolution; + code?: string; + error?: string; +};