ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hardening Voice Tasker routing, сроков и transcript

This commit is contained in:
DCCONSTRUCTIONS 2026-04-24 21:54:34 +03:00
parent d3b47326da
commit 597480adb9
9 changed files with 1629 additions and 227 deletions

View File

@ -52,11 +52,12 @@ Voice Tasker не создает отдельную модель задачи.
1. workspace AI settings;
2. encrypted workspace credentials;
3. voice sessions;
4. voice memory.
3. voice sessions.
Не создавать отдельные `VoiceTask`, `VoiceProject`, `VoiceUser`, `VoiceInbox` как обязательные бизнес-сущности.
Voice memory в MVP не является отдельной бизнес-сущностью и не требует отдельной таблицы. Последние voice-действия восстанавливаются из `voice_task_sessions.created_task_id`, `voice_task_sessions.updated_task_id`, `parsed_json`, `created_at`, `updated_at`. Отдельная `voice_task_memory` допустима только как будущая оптимизация, если session-backed memory перестанет закрывать продуктовый сценарий.
### 2.2. Backend-only OpenAI
Frontend:
@ -75,7 +76,7 @@ Backend:
- валидирует JSON;
- резолвит project/member;
- создает/редактирует work item через внутренние Plane модели/serializer/service;
- пишет voice session и memory.
- пишет voice session и session-backed memory.
### 2.3. Не использовать внешний Plane REST API
@ -148,10 +149,27 @@ Native поля "deadline time" у work item сейчас нет.
2. фраза "до 15:00" сохраняется в draft как `due_time`;
3. при commit `due_time` не создает новое поле в `Issue`;
4. если время важно, оно добавляется в `description_html` отдельной строкой, например: `Ориентир по времени: до 15:00`;
5. в `voice_task_sessions.parsed_json` время сохраняется как структурированное значение для будущей миграции.
5. относительные сроки вида "на две недели вперед", "на месяц назад", "через два месяца и две недели", "на год перенеси" нормализуются backend rule-based слоем относительно даты пользователя/workspace;
6. если задача уже имеет срок и команда звучит как перенос/сдвиг, допустимо считать от текущего `Issue.target_date`;
7. в `voice_task_sessions.parsed_json` время сохраняется как структурированное значение для будущей миграции.
Отдельное native поле дедлайна со временем (`target_datetime`, `due_at` или аналог) не входит в MVP и выносится в backlog как архитектурное расширение.
### 3.5. Статус задачи
В текущей модели Plane статус work item хранится через `Issue.state_id` и проектные `State`.
MVP-правило:
1. если пользователь явно говорит "в работе", "в реализации", "в реализацию", "активный статус", "в активном статусе" - resolver выбирает state из group `started`;
2. если пользователь явно называет статус проекта - resolver делает exact/fuzzy match по имени state;
3. если модель не вернула `state_hint`, backend делает ограниченный fallback по явной status-фразе из transcript;
4. если статус не назван, backend не должен отдавать выбор случайному default/последнему статусу Plane;
5. default для новой voice-задачи: первый `unstarted` state проекта, затем первый `started`, затем backlog только как последний fallback;
6. `completed`/`cancelled` допустимы только при явном status/state hint пользователя.
Цель: voice-create не должен создавать задачи в закрытых/отложенных статусах и не должен попадать в backlog, если в проекте есть нормальный open-state.
---
## 4. Пользовательские сценарии
@ -170,6 +188,7 @@ Parser возвращает draft:
{
"intent": "create_task",
"project_hint": "контур бухгалтерии",
"state_hint": null,
"assignee_hint": "Настя / бухгалтер Настя",
"title": "Подготовить декларацию по НДС",
"description": "Необходимо подготовить декларацию по НДС.",
@ -202,10 +221,13 @@ Commit маппит draft в Plane:
MVP-правило:
1. если `project_confidence >= 0.8`, можно auto-create;
2. если проект не найден уверенно, показываем preview с ручным выбором project;
3. если admin заранее указал `default_project_id`, можно предложить его как fallback;
4. если fallback project не задан, задачу не создаем автоматически.
1. backend сначала ищет проект в самом transcript по именам, identifiers и утвержденным алиасам;
2. transcript-резолв имеет приоритет над `project_hint`, потому что модель может галлюцинировать текущий проект;
3. если `project_hint` найден уверенно и не конфликтует с transcript, можно auto-create;
4. если пользователь явно назвал контур/проект, но resolver не нашел его уверенно, запрещено тихо fallback-иться в current/default project;
5. если проект не найден уверенно, показываем preview с ручным выбором project;
6. если admin заранее указал `default_project_id`, можно использовать его только когда пользователь не называл другой проект явно;
7. если fallback project не задан, задачу не создаем автоматически.
Не создавать "общую помойку" автоматически.
@ -235,7 +257,16 @@ MVP-правило:
3. показывает preview изменения, если confidence низкий;
4. меняет `Issue.target_date`;
5. сохраняет `due_time` в description note / parsed JSON;
6. пишет новое действие в voice memory.
6. пишет новое действие в session-backed memory.
Если пользователь говорит "переложи последнюю задачу в проект X", это остается `update_task`, но backend должен:
1. найти target issue через session-backed memory;
2. отдельно зарезолвить целевой project из transcript/project hint;
3. показать в preview `project_change.from -> project_change.to`;
4. при commit перенести обычную `Issue` в целевой `Project`, выдать ей новый `sequence_id` целевого проекта и валидный `State`;
5. для фраз "из Бухгалтерии в Менеджмент" считать project после destination-маркера "в/во/на/to" целевым, а project после "из/from" исходным контекстом;
6. не считать update успешным, если целевой project не найден или у пользователя нет прав создать/редактировать задачу в целевом проекте.
### 4.5. Удаление последней voice-задачи
@ -249,7 +280,7 @@ MVP-правило:
- удаление всегда требует confirmation modal;
- backend повторно проверяет права на удаление;
- действие пишется в voice memory;
- действие пишется в session-backed memory;
- предпочтительно использовать тот же delete path, что обычный work item, чтобы сохранился activity log.
---
@ -294,8 +325,8 @@ recording - идет запись
uploading - отправка аудио
processing - транскрибация и разбор
success - draft разобран
committing - создание задачи
committed - задача создана / обновлена
committing - применение voice-действия
committed - задача создана / обновлена / удалена
error - ошибка
```
@ -316,7 +347,7 @@ error - ошибка
Кнопки:
- `Создать задачу` / `Применить изменения`;
- `Создать задачу` / `Применить изменения` / `Удалить задачу`;
- `Редактировать`;
- `Отмена`.
@ -397,7 +428,6 @@ Backend:
GET /api/workspaces/:workspaceSlug/voice-task/preflight
POST /api/workspaces/:workspaceSlug/voice-task/parse
POST /api/workspaces/:workspaceSlug/voice-task/commit
POST /api/workspaces/:workspaceSlug/voice-task/resolve-command
```
### 7.1. Preflight
@ -473,6 +503,7 @@ Response:
"intent": "create_task",
"target_memory_ref": null,
"project_hint": "контур бухгалтерии",
"state_hint": null,
"assignee_hint": "Настя",
"title": "Подготовить декларацию по НДС",
"description": "Необходимо подготовить декларацию по НДС.",
@ -497,6 +528,13 @@ Response:
"confidence": 0.91,
"source": "project_hint"
},
"state": {
"id": "state_uuid",
"name": "К выполнению",
"group": "unstarted",
"confidence": 0.65,
"source": "default_open_state"
},
"assignee": {
"id": "user_uuid",
"name": "Настя",
@ -510,6 +548,7 @@ Response:
"name": "voice"
}
],
"target_task": null,
"warnings": [],
"can_commit": true
},
@ -536,11 +575,12 @@ Payload:
```json
{
"voice_session_id": "uuid",
"action": "create_task",
"action": "create_task | update_task | delete_task",
"draft": {
"title": "Подготовить декларацию по НДС",
"description": "Необходимо подготовить декларацию по НДС.",
"project_id": "project_uuid",
"state_hint": null,
"assignee_ids": ["user_uuid"],
"due_date": "2026-04-24",
"due_time": "15:00",
@ -558,6 +598,7 @@ Internal Plane payload:
"description_html": "<p>Необходимо подготовить декларацию по НДС.</p><p><strong>Ориентир по времени:</strong> до 15:00</p>",
"target_date": "2026-04-24",
"priority": "high",
"state_id": "state_uuid",
"assignees": ["user_uuid"],
"labels": ["label_uuid"]
}
@ -583,6 +624,13 @@ Response:
"confidence": 0.91,
"source": "project_hint"
},
"state": {
"id": "state_uuid",
"name": "К выполнению",
"group": "unstarted",
"confidence": 0.65,
"source": "default_open_state"
},
"assignee": {
"id": "user_uuid",
"name": "Настя",
@ -591,13 +639,20 @@ Response:
"source": "assignee_hint"
},
"labels": [{ "id": "label_uuid", "name": "voice" }],
"target_task": null,
"warnings": [],
"can_commit": true
}
}
```
На Stage 4 commit поддерживает только `create_task`. `update_task` и `delete_task` остаются в следующих этапах, потому что требуют voice memory и отдельного confirmation policy.
Commit поддерживает:
- `create_task` - создает обычную `Issue`;
- `update_task` - находит target issue через session-backed memory и применяет частичный update;
- `delete_task` - находит target issue через session-backed memory и удаляет через тот же delete path, что обычный work item.
Для `update_task`/`delete_task` response содержит `resolution.target_task` с ключом, названием, проектом и источником резолва.
---
@ -675,28 +730,32 @@ Audio file в MVP не хранить после обработки.
Transcript и parsed JSON хранить для поддержки preview, отладки и memory. Retention policy нужно вынести в отдельную настройку после MVP.
### 8.4. `voice_task_memory`
### 8.4. Session-backed voice memory
Поля:
В MVP отдельная таблица `voice_task_memory` не создается.
Источник memory:
```txt
id
workspace_id
user_id
task_id
voice_session_id
action
summary
created_at
voice_task_sessions.workspace_id
voice_task_sessions.user_id
voice_task_sessions.intent
voice_task_sessions.parsed_json
voice_task_sessions.created_task_id
voice_task_sessions.updated_task_id
voice_task_sessions.created_at
voice_task_sessions.updated_at
```
Использование:
- хранить последние N voice-действий пользователя;
- N по умолчанию: 10;
- N по умолчанию для parser context: 5;
- резолвить "последняя задача", "предыдущая задача", "та задача в бухгалтерии";
- не использовать memory как источник истины вместо `Issue`.
Если в будущем понадобится отдельная агрегированная history/memory-таблица, она должна быть добавлена отдельным архитектурным этапом и не должна дублировать `Issue` как источник истины.
---
## 9. OpenAI pipeline
@ -741,7 +800,26 @@ Input:
"transcript": "...",
"workspace_projects": [],
"workspace_members": [],
"recent_voice_memory": [],
"recent_voice_memory": [
{
"voice_session_id": "uuid",
"intent": "create_task",
"title": "Подготовить декларацию по НДС",
"project_hint": "Бухгалтерия",
"target_task": {
"id": "issue_uuid",
"title": "Подготовить декларацию по НДС",
"key": "BUH-128",
"project_id": "project_uuid",
"project_name": "Бухгалтерия",
"project_identifier": "BUH",
"sequence_id": 128,
"source": "recent_voice_memory",
"voice_session_id": "uuid"
},
"created_at": "2026-04-24T10:00:00+03:00"
}
],
"current_date": "2026-04-24",
"timezone": "Europe/Moscow"
}
@ -752,7 +830,7 @@ Output строго JSON:
```json
{
"intent": "create_task | update_task | delete_task | unknown",
"target_memory_ref": "last_task | previous_task | explicit_task | null",
"target_memory_ref": "voice_session_id | issue_key | issue_id | null",
"project_hint": "string | null",
"assignee_hint": "string | null",
"title": "string | null",
@ -789,20 +867,37 @@ Return JSON only.
Вход:
- `project_hint`;
- transcript;
- список проектов workspace, доступных пользователю;
- `current_project_id`, если пользователь находится внутри проекта;
- `default_project_id` из settings.
Логика:
1. exact match по имени/identifier;
2. fuzzy match по имени;
3. current project как слабый fallback;
4. default project как fallback, если задан;
5. если confidence низкий - preview с ручным выбором.
1. exact/alias match по transcript: имя проекта, identifier, короткие алиасы (`MGR`, `BUH`, `CODEX`) и согласованные русские UX-формы вроде "контур менеджмент";
2. transcript match имеет приоритет над `project_hint`;
3. exact/fuzzy match по `project_hint` и candidates проекта;
4. current project как слабый fallback только если пользователь не назвал проект/контур явно;
5. default project как fallback только если пользователь не назвал проект/контур явно;
6. если confidence низкий - preview с ручным выбором;
7. если transcript содержит явную маршрутизацию проекта, но model `project_hint` указывает на проект, которого нет в transcript, auto-commit блокируется;
8. при наличии source/destination фразы выбирать destination project: "из Бухгалтерии в Менеджмент" -> target `Менеджмент`, "из Менеджмента в Бухгалтерию" -> target `Бухгалтерия`.
Не зашивать термин "контур" как обязательный. Для NODE DC это важный UX-термин, но технически это обычный `Project`.
### 10.1.1. Перенос задачи между проектами
Команда вида "переложи последнюю задачу в проект Менеджмент" не создает новую задачу.
Правило:
1. target issue находится через session-backed memory;
2. целевой project резолвится тем же project resolver, но без current/default fallback;
3. если целевой project отличается от исходного, response содержит `resolution.project_change`;
4. commit переносит `Issue.project_id`, выдает новый `sequence_id` целевого проекта через `IssueSequence`, выбирает state целевого проекта по явному `state_hint`, group исходного state или default open-state;
5. assignees/labels сохраняются только если они валидны в целевом project;
6. при неуверенном project resolver или недостатке прав перенос не выполняется.
### 10.2. Assignee resolver
Вход:
@ -820,15 +915,60 @@ Return JSON only.
Назначать можно только пользователей, которые состоят в project и имеют достаточную роль.
### 10.3. Date resolver
### 10.3. State resolver
Вход:
- `state_hint`;
- states выбранного project;
- transcript как fallback, если модель не вернула `state_hint`;
- group state: `backlog`, `unstarted`, `started`, `completed`, `cancelled`.
Логика:
1. exact/fuzzy match по имени state;
2. словари синонимов для group:
- "в работе", "в реализации", "в реализацию", "активный", "активном статусе" -> `started`;
- "к выполнению", "todo", "новая" -> `unstarted`;
- "бэклог", "backlog" -> `backlog`;
- "готово", "закрыто" -> `completed`;
3. если `state_hint` отсутствует, но transcript содержит явную status-фразу, использовать ее как backend fallback;
4. если `state_hint` отсутствует при создании - выбрать default open-state: сначала `unstarted`, затем `started`, затем backlog;
5. не выбирать `completed`/`cancelled` без явного state hint пользователя.
### 10.4. Date resolver
MVP:
- сегодня;
- завтра;
- послезавтра / вчера / позавчера;
- `N` дней/недель/месяцев/лет вперед;
- `N` дней/недель/месяцев/лет назад;
- сложные интервалы: "два месяца и две недели";
- числительные цифрами и частые русские словоформы: "2 недели", "две недели", "пару дней";
- защита от ложных матчей внутри слов: "последней" не считается как "дней";
- конкретная дата;
- конкретное время как `due_time` note.
Date resolver обязан работать после OpenAI parser как deterministic fallback. Если модель уже вернула валидный `due_date`, backend его не переписывает.
### 10.5. Voice task representation in Issue
Voice-created/voice-updated work item остается обычной `Issue`, но получает техническую маркировку:
- `Issue.external_source = voice_tasker`;
- `Issue.external_id = voice_session_id`.
Описание voice-created задачи формируется многоуровнево:
1. источник `Voice Tasker`;
2. подробная постановка из parser `description`;
3. декомпозиция из `checklist`, если она есть;
4. исходная транскрибация пользователя отдельным блоком `Исходная транскрибация пользователя`.
Kanban/list UI может использовать `external_source=voice_tasker` для отличимого отображения voice-задач без создания отдельной бизнес-сущности.
Backlog:
- к пятнице;
@ -927,7 +1067,7 @@ MVP:
- хранить transcript N дней;
- очищать transcript после commit;
- хранить только parsed JSON;
- отключать voice memory для sensitive workspace.
- отключать session-backed voice memory для sensitive workspace.
---
@ -991,6 +1131,7 @@ voice_task.error
### Stage 4 - Preview и создание задачи
- project resolver;
- state resolver и безопасный default open-state;
- assignee resolver;
- date resolver MVP;
- preview modal;
@ -998,14 +1139,19 @@ voice_task.error
- создание `Issue` через внутренний Plane layer;
- activity log/model activity как у обычного work item;
- точечное обновление активного issue-store после commit без reload/polling;
- `voice_task_memory` для created action.
- session-backed memory для created action.
### Stage 5 - Memory commands
- update last task;
- delete last task with confirmation;
- append description/checklist to last task;
- memory resolver для "последняя/предыдущая/та задача".
- memory resolver для "последняя/предыдущая/та задача";
- transcript-first project routing и базовые project aliases;
- перенос последней voice-задачи между проектами через обычную `Issue`.
- относительные сроки русским естественным языком;
- маркировка voice-задач через `Issue.external_source`;
- сохранение полного transcript в description_html созданной/обновленной задачи.
---
@ -1017,7 +1163,7 @@ voice_task.error
- Voice Inbox как отдельный управляемый fallback project/triage flow;
- Redis/Celery-backed VoiceTaskQueue;
- transcript retention policies;
- project/member aliases;
- расширяемые project/member aliases в настройках workspace;
- выбранные роли beyond `all members/admins only`;
- monthly budget/soft cap;
- multi-provider AI;
@ -1039,18 +1185,22 @@ voice_task.error
7. Backend транскрибирует через OpenAI.
8. Backend формирует валидный structured draft.
9. Project resolver выбирает project или требует ручной выбор.
10. Assignee resolver назначает только уверенно найденного project member.
11. Если assignee не найден - задача может быть создана без assignee.
12. Commit создает обычную `Issue` через внутренний Plane backend layer.
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.
18. Delete last task требует confirmation.
19. User/workspace limits работают.
20. OpenAI errors показываются пользователю как нормальные сообщения без raw stack trace.
10. State resolver выбирает явный state или безопасный default open-state.
11. Assignee resolver назначает только уверенно найденного project member.
12. Если assignee не найден - задача может быть создана без assignee.
13. Commit создает обычную `Issue` через внутренний Plane backend layer.
14. После commit активная доска/список обновляется без reload страницы.
15. `due_date` маппится в `target_date`.
16. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP.
17. Voice session сохраняется.
18. Последняя voice-задача сохраняется в session-backed memory.
19. Update last task работает минимум для `target_date`, state и description.
20. Delete last task требует confirmation.
21. User/workspace limits работают.
22. OpenAI errors показываются пользователю как нормальные сообщения без raw stack trace.
23. Относительная дата "на две недели вперед" меняет `Issue.target_date` без ручного ввода ISO-даты.
24. Перенос "из Бухгалтерии в Менеджмент" и "из Менеджмента в Бухгалтерию" реально меняет `Issue.project_id` и `task_key`.
25. Voice-created task имеет `external_source=voice_tasker` и хранит исходный transcript в description.
---

View File

@ -799,6 +799,8 @@ class IssueSerializer(DynamicBaseSerializer):
"link_count",
"is_draft",
"archived_at",
"external_source",
"external_id",
]
read_only_fields = fields
@ -852,6 +854,8 @@ class IssueListDetailSerializer(serializers.Serializer):
"updated_by": instance.updated_by_id,
"is_draft": instance.is_draft,
"archived_at": instance.archived_at,
"external_source": instance.external_source,
"external_id": instance.external_id,
"source_project_name": getattr(instance, "source_project_name", None),
# Computed fields
"cycle_id": instance.cycle_id,

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useOutsideClickDetector } from "@plane/hooks";
// plane helpers
import { MoreHorizontal } from "lucide-react";
import { Mic, MoreHorizontal } from "lucide-react";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
@ -99,6 +99,7 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
// derived values
const subIssueCount = issue?.sub_issues_count ?? 0;
const isVoiceTask = issue?.external_source === "voice_tasker";
const handleEventPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
@ -151,6 +152,13 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
</div>
</Tooltip>
{isVoiceTask && (
<div className="inline-flex max-w-full items-center gap-1 rounded border border-pink-500/30 bg-pink-500/10 px-1.5 py-0.5 text-11 font-medium text-pink-300">
<Mic className="h-3 w-3 shrink-0" />
<span>Voice</span>
</div>
)}
<IssueProperties
className="flex flex-wrap items-center gap-2 pt-1.5 whitespace-nowrap text-tertiary"
issue={issue}

View File

@ -7,7 +7,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { CheckCircle2, Mic, Plus, RotateCcw, Square, Upload, X } from "lucide-react";
import { CheckCircle2, Mic, Pencil, Plus, RotateCcw, Square, Trash2, Upload, X } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
import { Tooltip } from "@plane/propel/tooltip";
@ -61,6 +61,43 @@ function getRouteParam(value: string | string[] | undefined) {
return value?.toString();
}
function getCommitButtonLabel(intent?: string) {
if (intent === "update_task") return "Применить изменения";
if (intent === "delete_task") return "Удалить задачу";
return "Создать задачу";
}
function getCommitStatusLabel(status: TVoiceTaskerStatus) {
if (status === "committed") return "Committed";
if (status === "success") return "Draft parsed";
if (status === "committing") return "Committing";
if (status === "uploading") return "Processing";
if (status === "recording") return "Recording";
if (status === "error") return "Error";
return "Ready";
}
function getCommitSuccessTitle(result: TVoiceTaskCommitResult) {
if (result.status === "updated") return "Задача обновлена";
if (result.status === "deleted") return "Задача удалена";
return "Задача создана";
}
function getCommitSuccessMessage(result: TVoiceTaskCommitResult) {
if (result.task_key && result.status === "updated") return `Обновлена ${result.task_key}`;
if (result.task_key && result.status === "deleted") return `Удалена ${result.task_key}`;
if (result.task_key) return `Создана ${result.task_key}`;
if (result.status === "updated") return "Work item обновлен.";
if (result.status === "deleted") return "Work item удален.";
return "Work item создан.";
}
function getVoiceTaskWarnings(result: TVoiceTaskUploadResult) {
return Array.from(
new Set([...(result.warnings ?? []), ...(result.resolution?.warnings ?? []), ...(result.draft?.questions ?? [])])
);
}
type Props = {
workspaceSlug: string;
};
@ -174,7 +211,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
}, [audioBlob]);
const startRecording = async () => {
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === "undefined"
) {
setError("Браузер не поддерживает запись аудио.");
setStatus("error");
return;
@ -230,6 +271,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setStatus("uploading");
setError(null);
setParseResult(null);
setCommitResult(null);
const audioType = audioBlob.type || "audio/webm";
const extension = audioType.includes("mp4") ? "m4a" : "webm";
@ -256,7 +298,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
message: "Transcript и draft получены.",
});
} catch (err) {
const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио.";
const message =
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио.";
setError(message);
setStatus("error");
setToast({
@ -299,13 +342,23 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const commitVoiceTask = async () => {
if (!parseResult?.voice_session_id || !parseResult.draft) return;
const action = parseResult.draft.intent;
if (action === "unknown") return;
if (action === "delete_task") {
const targetTitle =
parseResult.resolution?.target_task?.key || parseResult.resolution?.target_task?.title || "последнюю задачу";
const confirmed = window.confirm(`Удалить ${targetTitle}?`);
if (!confirmed) return;
}
setStatus("committing");
setError(null);
try {
const result = await workspaceAIService.commitVoiceTask(workspaceSlug, {
voice_session_id: parseResult.voice_session_id,
action: "create_task",
action,
draft: parseResult.draft,
});
await refreshVisibleIssueStores(result.project_id);
@ -313,16 +366,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setStatus("committed");
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Задача создана",
message: result.task_key ? `Создана ${result.task_key}` : "Work item создан.",
title: getCommitSuccessTitle(result),
message: getCommitSuccessMessage(result),
});
} catch (err) {
const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось создать задачу.";
const message =
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task.";
setError(message);
setStatus("error");
setToast({
type: TOAST_TYPE.ERROR,
title: "Задача не создана",
title: "Voice Task не применен",
message,
});
}
@ -330,14 +384,14 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
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 bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)] z-[29]">
<Tooltip tooltipContent={tooltipContent} position="left">
<button
type="button"
className={cn(
"pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] shadow-lg transition",
"shadow-lg pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] transition",
isAvailable
? "border-pink-500/40 bg-pink-500 text-white hover:bg-pink-600"
? "border-pink-500/40 bg-pink-500 hover:bg-pink-600 text-white"
: "cursor-not-allowed border-subtle bg-layer-2 text-tertiary"
)}
disabled={!isAvailable}
@ -368,19 +422,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary">
{status === "committed"
? "Created"
: status === "success"
? "Draft parsed"
: isCommitting
? "Creating"
: isUploading
? "Processing"
: isRecording
? "Recording"
: "Ready"}
</div>
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
</div>
<div
className={cn(
@ -399,7 +441,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)}
{error && (
<div className="mt-4 rounded-md border-[0.5px] border-red-500/30 bg-red-500/10 px-3 py-2 text-12 text-red-500">
<div className="border-red-500/30 bg-red-500/10 text-red-500 mt-4 rounded-md border-[0.5px] px-3 py-2 text-12">
{error}
</div>
)}
@ -407,13 +449,13 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{parseResult?.draft && (
<div className="mt-4 space-y-3 rounded-md border-[0.5px] border-subtle bg-layer-2 p-3">
<div className="flex items-center gap-2 text-13 font-medium text-primary">
<CheckCircle2 className="size-4 text-green-500" />
<CheckCircle2 className="text-green-500 size-4" />
Draft готов
</div>
{parseResult.transcript && (
<div>
<div className="text-11 font-medium uppercase text-tertiary">Транскрипт</div>
<div className="text-11 font-medium text-tertiary uppercase">Транскрипт</div>
<p className="mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary">
{parseResult.transcript}
</p>
@ -422,40 +464,66 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<div className="grid grid-cols-1 gap-2 text-12 sm:grid-cols-2">
<div>
<div className="text-11 font-medium uppercase text-tertiary">Название</div>
<div className="text-11 font-medium text-tertiary uppercase">Название</div>
<div className="mt-0.5 text-primary">{parseResult.draft.title || "не распознано"}</div>
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Intent</div>
<div className="text-11 font-medium text-tertiary uppercase">Intent</div>
<div className="mt-0.5 text-primary">{parseResult.draft.intent}</div>
</div>
{parseResult.resolution?.target_task && (
<div className="sm:col-span-2">
<div className="text-11 font-medium text-tertiary uppercase">Целевая задача</div>
<div className="mt-0.5 text-primary">
{[parseResult.resolution.target_task.key, parseResult.resolution.target_task.title]
.filter(Boolean)
.join(" · ")}
</div>
</div>
)}
{parseResult.resolution?.project_change && (
<div className="sm:col-span-2">
<div className="text-11 font-medium text-tertiary uppercase">Перенос проекта</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution.project_change.from.name} -&gt;{" "}
{parseResult.resolution.project_change.to.name}
</div>
</div>
)}
<div>
<div className="text-11 font-medium uppercase text-tertiary">Проект</div>
<div className="text-11 font-medium text-tertiary uppercase">Проект</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution?.project?.name || parseResult.draft.project_hint || "не распознано"}
</div>
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Исполнитель</div>
<div className="text-11 font-medium text-tertiary uppercase">Статус</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution?.state?.name || parseResult.draft.state_hint || "не распознано"}
</div>
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Исполнитель</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution?.assignee?.name || parseResult.draft.assignee_hint || "не распознано"}
</div>
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Срок</div>
<div className="text-11 font-medium text-tertiary uppercase">Срок</div>
<div className="mt-0.5 text-primary">
{[parseResult.draft.due_date, parseResult.draft.due_time].filter(Boolean).join(" ") || "не распознано"}
{[parseResult.draft.due_date, parseResult.draft.due_time].filter(Boolean).join(" ") ||
"не распознано"}
</div>
</div>
<div>
<div className="text-11 font-medium uppercase text-tertiary">Приоритет</div>
<div className="text-11 font-medium text-tertiary uppercase">Приоритет</div>
<div className="mt-0.5 text-primary">{parseResult.draft.priority || "не распознано"}</div>
</div>
</div>
{parseResult.draft.description && (
<div>
<div className="text-11 font-medium uppercase text-tertiary">Описание</div>
<div className="text-11 font-medium text-tertiary uppercase">Описание</div>
<p className="mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary">
{parseResult.draft.description}
</p>
@ -463,10 +531,18 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)}
<div className="flex flex-wrap gap-1.5 text-11 text-secondary">
<span className="rounded bg-layer-1 px-2 py-1">intent {formatConfidence(parseResult.draft.confidence.intent)}</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">task {formatConfidence(parseResult.draft.confidence.task)}</span>
<span className="rounded bg-layer-1 px-2 py-1">
intent {formatConfidence(parseResult.draft.confidence.intent)}
</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">
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)}
@ -474,15 +550,15 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)}
</div>
{Boolean(parseResult.warnings?.length || parseResult.draft.questions.length) && (
<div className="rounded border-[0.5px] border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-11 text-yellow-600">
{[...(parseResult.warnings ?? []), ...parseResult.draft.questions].join(" · ")}
{Boolean(getVoiceTaskWarnings(parseResult).length) && (
<div className="border-yellow-500/30 bg-yellow-500/10 text-yellow-600 rounded border-[0.5px] px-3 py-2 text-11">
{getVoiceTaskWarnings(parseResult).join(" · ")}
</div>
)}
{commitResult?.task_key && (
<div className="rounded border-[0.5px] border-green-500/30 bg-green-500/10 px-3 py-2 text-12 text-green-600">
Создана задача {commitResult.task_key}
<div className="border-green-500/30 bg-green-500/10 text-green-600 rounded border-[0.5px] px-3 py-2 text-12">
{getCommitSuccessMessage(commitResult)}
</div>
)}
</div>
@ -505,6 +581,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
{isRecording ? "Стоп" : "Записать"}
</Button>
{!parseResult?.draft && (
<Button
variant="primary"
size="lg"
@ -515,16 +592,23 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<Upload className="mr-2 size-4" />
Отправить
</Button>
{parseResult?.draft?.intent === "create_task" && !commitResult?.task_id && (
)}
{parseResult?.draft?.intent && parseResult.draft.intent !== "unknown" && !commitResult?.task_id && (
<Button
variant="primary"
variant={parseResult.draft.intent === "delete_task" ? "error-fill" : "primary"}
size="lg"
onClick={commitVoiceTask}
loading={isCommitting}
disabled={!parseResult.voice_session_id || !parseResult.resolution?.can_commit || isUploading}
>
{parseResult.draft.intent === "update_task" ? (
<Pencil className="mr-2 size-4" />
) : parseResult.draft.intent === "delete_task" ? (
<Trash2 className="mr-2 size-4" />
) : (
<Plus className="mr-2 size-4" />
Создать задачу
)}
{getCommitButtonLabel(parseResult.draft.intent)}
</Button>
)}
</div>

View File

@ -29,10 +29,7 @@ export class WorkspaceAIService extends APIService {
});
}
async updateSettings(
workspaceSlug: string,
data: TWorkspaceAISettingsPayload
): Promise<TWorkspaceAISettings> {
async updateSettings(workspaceSlug: string, data: TWorkspaceAISettingsPayload): Promise<TWorkspaceAISettings> {
return this.patch(`/api/workspaces/${workspaceSlug}/voice-tasker/settings/`, data)
.then((response) => response?.data)
.catch((error) => {
@ -68,7 +65,7 @@ export class WorkspaceAIService extends APIService {
workspaceSlug: string,
data: {
voice_session_id: string;
action: "create_task";
action: "create_task" | "update_task" | "delete_task";
draft?: TVoiceTaskDraft;
}
): Promise<TVoiceTaskCommitResult> {

View File

@ -26,11 +26,29 @@ http {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-XSS-Protection "1; mode=block" always;
location = /sw.js {
root /usr/share/nginx/html;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri =404;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri =404;
}
location ~* ^/assets/.+\.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2)$ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri $uri/ /index.html;
}
}
}

View File

@ -84,6 +84,7 @@ export type TVoiceTaskDraft = {
target_memory_ref: string | null;
project_id?: string | null;
project_hint: string | null;
state_hint: string | null;
assignee_hint: string | null;
title: string | null;
description: string | null;
@ -102,13 +103,7 @@ export type TVoiceTaskDraft = {
};
export type TVoiceTaskResolution = {
project: {
id: string;
name: string;
identifier: string;
confidence: number;
source: string | null;
} | null;
project: TVoiceTaskResolvedProject | null;
assignee: {
id: string;
name: string;
@ -120,10 +115,40 @@ export type TVoiceTaskResolution = {
id: string;
name: string;
}[];
state: {
id: string;
name: string;
group: string;
confidence: number;
source: string | null;
} | null;
target_task: {
id: string;
title: string;
key: string | null;
project_id: string;
project_name: string;
project_identifier: string;
sequence_id: number;
source: string | null;
voice_session_id: string | null;
} | null;
project_change: {
from: TVoiceTaskResolvedProject;
to: TVoiceTaskResolvedProject;
} | null;
warnings: string[];
can_commit: boolean;
};
export type TVoiceTaskResolvedProject = {
id: string;
name: string;
identifier: string;
confidence: number;
source: string | null;
};
export type TVoiceTaskUploadResult = {
ok: boolean;
status?: "uploaded" | "parsed";
@ -151,7 +176,7 @@ export type TVoiceTaskUploadResult = {
export type TVoiceTaskCommitResult = {
ok: boolean;
status?: "created";
status?: "created" | "updated" | "deleted";
voice_session_id?: string;
task_id?: string;
task_key?: string;

View File

@ -78,6 +78,8 @@ export type TBaseIssue = {
is_draft: boolean;
is_epic?: boolean;
is_intake?: boolean;
external_source?: string | null;
external_id?: string | null;
};
type IssueRelation = {