ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: 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

View File

@ -293,7 +293,9 @@ idle - обычная кнопка микрофона
recording - идет запись recording - идет запись
uploading - отправка аудио uploading - отправка аудио
processing - транскрибация и разбор processing - транскрибация и разбор
success - задача создана / обновлена success - draft разобран
committing - создание задачи
committed - задача создана / обновлена
error - ошибка error - ошибка
``` ```
@ -318,6 +320,8 @@ error - ошибка
- `Редактировать`; - `Редактировать`;
- `Отмена`. - `Отмена`.
После успешного commit frontend обязан выполнить точечный mutation-refresh активного issue-store, если пользователь находится на проектной доске, project view или global view, куда может попасть созданная задача. Это не polling и не reload страницы: обновляется только уже открытый список/доска через существующую Plane store-модель.
Auto-create допустим только если: Auto-create допустим только если:
```txt ```txt
@ -485,6 +489,30 @@ Response:
}, },
"questions": [] "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": [], "warnings": [],
"requires_confirmation": true, "requires_confirmation": true,
"models": { "models": {
@ -539,12 +567,38 @@ Response:
```json ```json
{ {
"ok": true,
"status": "created", "status": "created",
"voice_session_id": "uuid",
"task_id": "task_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 ## 8. Database
@ -943,6 +997,7 @@ voice_task.error
- commit endpoint; - commit endpoint;
- создание `Issue` через внутренний Plane layer; - создание `Issue` через внутренний Plane layer;
- activity log/model activity как у обычного work item; - activity log/model activity как у обычного work item;
- точечное обновление активного issue-store после commit без reload/polling;
- `voice_task_memory` для created action. - `voice_task_memory` для created action.
### Stage 5 - Memory commands ### Stage 5 - Memory commands
@ -967,6 +1022,7 @@ voice_task.error
- monthly budget/soft cap; - monthly budget/soft cap;
- multi-provider AI; - multi-provider AI;
- streaming/realtime voice; - streaming/realtime voice;
- realtime task event stream для ситуационных панелей без reload/polling;
- audio debug retention для dev/staging; - audio debug retention для dev/staging;
- автоматическое создание label `voice` / `needs-assignee-review` по настройке. - автоматическое создание label `voice` / `needs-assignee-review` по настройке.
@ -986,8 +1042,9 @@ voice_task.error
10. Assignee resolver назначает только уверенно найденного project member. 10. Assignee resolver назначает только уверенно найденного project member.
11. Если assignee не найден - задача может быть создана без assignee. 11. Если assignee не найден - задача может быть создана без assignee.
12. Commit создает обычную `Issue` через внутренний Plane backend layer. 12. Commit создает обычную `Issue` через внутренний Plane backend layer.
13. `due_date` маппится в `target_date`. 13. После commit активная доска/список обновляется без reload страницы.
14. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP. 14. `due_date` маппится в `target_date`.
15. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP.
15. Voice session сохраняется. 15. Voice session сохраняется.
16. Последняя voice-задача сохраняется в memory. 16. Последняя voice-задача сохраняется в memory.
17. Update last task работает минимум для `target_date` и description. 17. Update last task работает минимум для `target_date` и description.

View File

@ -5,6 +5,7 @@
from django.urls import path from django.urls import path
from plane.app.views import ( from plane.app.views import (
VoiceTaskCommitEndpoint,
VoiceTaskParseEndpoint, VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint, VoiceTaskPreflightEndpoint,
WorkspaceAISettingsEndpoint, WorkspaceAISettingsEndpoint,
@ -33,4 +34,9 @@ urlpatterns = [
VoiceTaskParseEndpoint.as_view(), VoiceTaskParseEndpoint.as_view(),
name="voice-task-parse", name="voice-task-parse",
), ),
path(
"workspaces/<str:slug>/voice-task/commit/",
VoiceTaskCommitEndpoint.as_view(),
name="voice-task-commit",
),
] ]

View File

@ -244,6 +244,7 @@ from .webhook.base import (
) )
from .voice_tasker import ( from .voice_tasker import (
VoiceTaskCommitEndpoint,
VoiceTaskParseEndpoint, VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint, VoiceTaskPreflightEndpoint,
WorkspaceAISettingsEndpoint, WorkspaceAISettingsEndpoint,

View File

@ -4,8 +4,11 @@
import json import json
import re import re
from difflib import SequenceMatcher
from html import escape
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone from django.utils import timezone
from openai import OpenAI from openai import OpenAI
@ -15,9 +18,15 @@ from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission 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 ( from plane.db.models import (
Issue,
Label,
Project, Project,
ProjectMember,
VoiceTaskSession, VoiceTaskSession,
Workspace, Workspace,
WorkspaceAICredential, WorkspaceAICredential,
@ -26,6 +35,7 @@ from plane.db.models import (
) )
from plane.license.utils.encryption import decrypt_data from plane.license.utils.encryption import decrypt_data
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
from .base import BaseAPIView 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_PRIORITIES = {"none", "low", "medium", "high", "urgent"}
VOICE_TASK_MEMORY_LIMIT = 5 VOICE_TASK_MEMORY_LIMIT = 5
VOICE_TASK_CONTEXT_LIMIT = 100 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}$") DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
TIME_PATTERN = re.compile(r"^\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 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() workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first()
projects = Project.objects.filter(workspace=workspace, archived_at__isnull=True) projects = Project.objects.filter(workspace=workspace, archived_at__isnull=True)
if not workspace_member or workspace_member.role != ROLE.ADMIN.value: if not workspace_member:
projects = projects.filter(project_projectmember__member=user, project_projectmember__is_active=True) 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 [ return [
{ {
"id": str(project.id), "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"<p>{escape(draft['description'])}</p>")
if draft.get("due_time"):
parts.append(f"<p><strong>Ориентир по времени:</strong> до {escape(draft['due_time'])}</p>")
checklist = draft.get("checklist") if isinstance(draft.get("checklist"), list) else []
if checklist:
items = "".join(f"<li>{escape(item)}</li>" for item in checklist if item)
if items:
parts.append(f"<p><strong>Checklist:</strong></p><ul>{items}</ul>")
return "".join(parts) or "<p></p>"
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): def serialize_workspace_members(workspace):
members = WorkspaceMember.objects.filter( members = WorkspaceMember.objects.filter(
workspace=workspace, workspace=workspace,
@ -641,6 +896,14 @@ class VoiceTaskParseEndpoint(BaseAPIView):
model=ai_settings.structuring_model, model=ai_settings.structuring_model,
).parse(parser_context) ).parse(parser_context)
warnings = get_voice_task_warnings(parsed, transcript) 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) requires_confirmation = voice_task_requires_confirmation(parsed, warnings)
voice_session.status = VoiceTaskSession.Status.PARSED voice_session.status = VoiceTaskSession.Status.PARSED
@ -657,6 +920,7 @@ class VoiceTaskParseEndpoint(BaseAPIView):
"transcript": transcript, "transcript": transcript,
"intent": parsed["intent"], "intent": parsed["intent"],
"draft": parsed, "draft": parsed,
"resolution": resolution,
"warnings": warnings, "warnings": warnings,
"requires_confirmation": requires_confirmation, "requires_confirmation": requires_confirmation,
"models": { "models": {
@ -691,3 +955,151 @@ class VoiceTaskParseEndpoint(BaseAPIView):
}, },
status=pipeline_error.response_status, 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,
)

View File

@ -5,21 +5,24 @@
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import useSWR from "swr"; 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 // plane imports
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; 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 { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
import { useIssues } from "@/hooks/store/use-issues";
// services // services
import { WorkspaceAIService } from "@/services/workspace-ai.service"; import { WorkspaceAIService } from "@/services/workspace-ai.service";
const workspaceAIService = new WorkspaceAIService(); const workspaceAIService = new WorkspaceAIService();
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "error"; type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "committing" | "committed" | "error";
const UNAVAILABLE_LABELS = { const UNAVAILABLE_LABELS = {
disabled: "AI-функции не активированы для этого workspace", disabled: "AI-функции не активированы для этого workspace",
@ -47,11 +50,36 @@ function formatConfidence(value?: number) {
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`; 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 = { type Props = {
workspaceSlug: string; workspaceSlug: string;
}; };
export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { 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 [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<TVoiceTaskerStatus>("idle"); const [status, setStatus] = useState<TVoiceTaskerStatus>("idle");
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
@ -59,6 +87,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null); const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
@ -76,6 +105,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const isAvailable = !!preflight?.available; const isAvailable = !!preflight?.available;
const isRecording = status === "recording"; const isRecording = status === "recording";
const isUploading = status === "uploading"; const isUploading = status === "uploading";
const isCommitting = status === "committing";
const tooltipContent = useMemo(() => { const tooltipContent = useMemo(() => {
if (!preflight) return "Voice Task"; if (!preflight) return "Voice Task";
@ -114,6 +144,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setDuration(0); setDuration(0);
setError(null); setError(null);
setParseResult(null); setParseResult(null);
setCommitResult(null);
setStatus("idle"); setStatus("idle");
}, [stopRecording]); }, [stopRecording]);
@ -209,6 +240,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
"client_context", "client_context",
JSON.stringify({ JSON.stringify({
current_page: window.location.pathname, current_page: window.location.pathname,
current_project_id: getCurrentProjectId(),
locale: navigator.language, locale: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, 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 ( 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 z-[29] bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)]">
@ -276,7 +369,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<div> <div>
<div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div> <div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary"> <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> </div>
<div <div
@ -328,11 +431,15 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
</div> </div>
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Проект</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> <div>
<div className="text-11 font-medium uppercase text-tertiary">Исполнитель</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> <div>
<div className="text-11 font-medium uppercase text-tertiary">Срок</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">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">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">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> </div>
{Boolean(parseResult.warnings?.length || parseResult.draft.questions.length) && ( {Boolean(parseResult.warnings?.length || parseResult.draft.questions.length) && (
@ -367,13 +479,19 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{[...(parseResult.warnings ?? []), ...parseResult.draft.questions].join(" · ")} {[...(parseResult.warnings ?? []), ...parseResult.draft.questions].join(" · ")}
</div> </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> </div>
<div className="mt-5 flex flex-wrap justify-end gap-2"> <div className="mt-5 flex flex-wrap justify-end gap-2">
{audioBlob && !isRecording && ( {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" /> <RotateCcw className="mr-2 size-4" />
Перезаписать Перезаписать
</Button> </Button>
@ -382,15 +500,33 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
variant={isRecording ? "error-fill" : "secondary"} variant={isRecording ? "error-fill" : "secondary"}
size="lg" size="lg"
onClick={isRecording ? stopRecording : startRecording} onClick={isRecording ? stopRecording : startRecording}
disabled={isUploading} disabled={isUploading || isCommitting}
> >
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />} {isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
{isRecording ? "Стоп" : "Записать"} {isRecording ? "Стоп" : "Записать"}
</Button> </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" /> <Upload className="mr-2 size-4" />
Отправить Отправить
</Button> </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>
</div> </div>
</ModalCore> </ModalCore>

View File

@ -6,7 +6,9 @@
import { API_BASE_URL } from "@plane/constants"; import { API_BASE_URL } from "@plane/constants";
import type { import type {
TVoiceTaskCommitResult,
TVoiceTaskPreflight, TVoiceTaskPreflight,
TVoiceTaskDraft,
TVoiceTaskUploadResult, TVoiceTaskUploadResult,
TWorkspaceAIConnectionTestResult, TWorkspaceAIConnectionTestResult,
TWorkspaceAISettings, TWorkspaceAISettings,
@ -61,4 +63,19 @@ export class WorkspaceAIService extends APIService {
throw error?.response?.data; 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;
});
}
} }

View File

@ -82,6 +82,7 @@ export type TVoiceTaskPriority = "none" | "low" | "medium" | "high" | "urgent" |
export type TVoiceTaskDraft = { export type TVoiceTaskDraft = {
intent: TVoiceTaskIntent; intent: TVoiceTaskIntent;
target_memory_ref: string | null; target_memory_ref: string | null;
project_id?: string | null;
project_hint: string | null; project_hint: string | null;
assignee_hint: string | null; assignee_hint: string | null;
title: string | null; title: string | null;
@ -100,6 +101,29 @@ export type TVoiceTaskDraft = {
questions: string[]; 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 = { export type TVoiceTaskUploadResult = {
ok: boolean; ok: boolean;
status?: "uploaded" | "parsed"; status?: "uploaded" | "parsed";
@ -108,6 +132,7 @@ export type TVoiceTaskUploadResult = {
transcript?: string; transcript?: string;
intent?: TVoiceTaskIntent; intent?: TVoiceTaskIntent;
draft?: TVoiceTaskDraft; draft?: TVoiceTaskDraft;
resolution?: TVoiceTaskResolution;
warnings?: string[]; warnings?: string[];
requires_confirmation?: boolean; requires_confirmation?: boolean;
models?: { models?: {
@ -123,3 +148,17 @@ export type TVoiceTaskUploadResult = {
code?: string; code?: string;
error?: 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;
};