ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: resolver, commit и обновление доски Voice Tasker
This commit is contained in:
parent
1a20e19a93
commit
d3b47326da
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,7 @@ from .webhook.base import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from .voice_tasker import (
|
from .voice_tasker import (
|
||||||
|
VoiceTaskCommitEndpoint,
|
||||||
VoiceTaskParseEndpoint,
|
VoiceTaskParseEndpoint,
|
||||||
VoiceTaskPreflightEndpoint,
|
VoiceTaskPreflightEndpoint,
|
||||||
WorkspaceAISettingsEndpoint,
|
WorkspaceAISettingsEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue