diff --git a/docs/TZ.md b/docs/TZ.md index 42eaa9a..59fdf57 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.86 | +| Версия ТЗ | 0.87 | | Дата | 2026-10-10 | | Статус | На обсуждении | @@ -1273,6 +1273,24 @@ - О работе агента узнают не только из журнала: взятие и закрытие публикуются по SSE (3.14) — пометка в интерфейсе и тост владельцу. +**5. Передача задачи агенту вручную** (0.87). Агент находит задачи сам, но владельцу +часто нужно отдать конкретную: на странице задачи в меню «⋯» есть пункт **«Промпт для +ИИ»** (подпись короткая намеренно — в китовом меню 220px длинная рвётся на две строки, +как в 0.83; само окно называется «Промпт для ИИ-агента»). Он собирает текст задачи — +id, заголовок, статус, тип, приоритет, оценку и факт, деньги, срок, проект, теги, +родителя, пометку доступности, живую аренду, описание, черновое предложение +детализации, подзадачи и ссылку на страницу — и показывает его в окне, где текст можно +поправить; копирует его публичная кнопка кита (`GnCopyButton`), так что в буфер +попадает ровно то, что в окне. + +- Содержимое — **только факты о задаче**: что с ней делать, владелец пишет сам в + своём чате с агентом. Кнопка не диктует агенту ни протокол работы, ни объём. +- Текст собирается **по открытию окна**, а не один раз при загрузке страницы: пока + окно открыто, задача успевает измениться (SSE), а в буфере должен оказаться её + текущий вид. +- Подписи полей — из локали, значения — теми же форматтерами, что в интерфейсе + (статус, приоритет, время, деньги, дедлайн), поэтому промпт и страница не расходятся. + **Актор действия.** У изменяющих MCP-тулов есть флаг `is_user`: по умолчанию действие считается **агентским**, `is_user=true` — «действую от имени владельца» (на REST тот же смысл у заголовка `X-Actor: user`, им объявляет себя браузерное расширение). В журнале у diff --git a/frontend/src/aiPrompt.ts b/frontend/src/aiPrompt.ts new file mode 100644 index 0000000..16655fa --- /dev/null +++ b/frontend/src/aiPrompt.ts @@ -0,0 +1,117 @@ +// Промпт для ИИ-агента (0.87): владелец копирует текст задачи с её страницы и +// отдаёт своему агенту. Внутри — только факты о задаче и ссылка на неё: указаний +// «что делать» здесь нет, это дело владельца, а не кнопки. Собирается из того же +// источника, что и карточки страницы, поэтому расхождений с интерфейсом не будет: +// подписи полей — из локали, значения — теми же форматтерами, что в taskui. +import type { Task } from './api' +import { + deadlineLabel, + formatMinutes, + formatMoney, + priorityLabel, + recurrenceLabel, + statusLabel, +} from './taskui' + +/** Функция перевода vue-i18n (нужна только именованная подстановка). */ +type Translate = (key: string, named?: Record) => string + +export interface PromptContext { + /** Подзадачи задачи — в промпт идут заголовками и статусами */ + subtasks?: Task[] + /** Родительская задача: агент должен видеть, частью чего является задача */ + parent?: Task | null + /** Готовая строка аренды («Взято Нави · до 14:30»), если задачу уже держит агент */ + lease?: string + /** Ссылка на страницу задачи в интерфейсе */ + url: string +} + +export function buildTaskPrompt( + task: Task, + context: PromptContext, + t: Translate, +): string { + const lines: string[] = [] + const add = (line = '') => lines.push(line) + const field = (label: string, value: unknown) => { + if (value === undefined || value === null || value === '') return + add(`- ${label}: ${value}`) + } + + add(t('task.ai.intro')) + add() + + add(`## ${t('task.ai.taskSection')}`) + field(t('task.ai.id'), `#${task.id}`) + field(t('task.ai.titleField'), task.title) + field(t('task.ai.status'), statusLabel(task.status)) + field( + t('task.field.taskType'), + task.task_type === 'recurring' + ? recurrenceLabel(task) || t('stack.recur.recurring') + : t('stack.recur.oneTime'), + ) + field(t('task.field.priority'), task.priority === null ? null : priorityLabel(task.priority)) + field( + t('task.field.estimate'), + task.estimated_minutes ? formatMinutes(task.estimated_minutes) : null, + ) + field(t('task.field.actual'), task.actual_minutes ? formatMinutes(task.actual_minutes) : null) + field(t('task.field.money'), moneyLine(task, t)) + field(t('task.field.deadline'), deadlineLabel(task)) + field(t('task.field.project'), task.project ? task.project.name : null) + field(t('task.ai.tags'), task.tags.map((tag) => tag.name).join(', ')) + field( + t('task.ai.parentTask'), + context.parent ? `#${context.parent.id} ${context.parent.title}` : null, + ) + field(t('task.ai.eligible'), task.ai_eligible ? t('task.ai.eligibleYes') : t('task.ai.eligibleNo')) + field(t('task.ai.claimed'), context.lease) + add() + + add(`## ${t('task.ai.descriptionSection')}`) + add(task.description.trim() || t('task.ai.descriptionEmpty')) + add() + + // Черновик автодетализации: задача ещё не утверждена, но описание с шагами в + // предложении уже есть — агенту оно полезнее пустого поля выше + const proposal = task.ai_proposal + if (proposal) { + const rows: string[] = [] + if (proposal.title) rows.push(`- ${t('task.proposalNewTitle')}: ${proposal.title}`) + if (proposal.description) + rows.push(`- ${t('task.proposalNewDescription')}: ${proposal.description}`) + if (proposal.tags.length) rows.push(`- ${t('stack.form.tags')}: ${proposal.tags.join(', ')}`) + if (rows.length) { + add(`## ${t('task.ai.proposalSection')}`) + rows.forEach(add) + add() + } + } + + const subtasks = context.subtasks ?? [] + if (subtasks.length) { + add(`## ${t('task.ai.subtasksSection')}`) + subtasks.forEach((child) => add(`- #${child.id} ${child.title} — ${statusLabel(child.status)}`)) + add() + } + + add(`## ${t('task.ai.linkSection')}`) + add(context.url) + return lines.join('\n') +} + +/** «бюджет 5 000 ₴ · оценка 3 000 ₴» — как в сайдбаре параметров страницы. */ +function moneyLine(task: Task, t: Translate): string { + return [ + task.budget_money !== null + ? t('taskui.money.budget', { amount: formatMoney(task.budget_money) }) + : '', + task.cost_estimate_money !== null + ? t('taskui.money.estimate', { amount: formatMoney(task.cost_estimate_money) }) + : '', + ] + .filter(Boolean) + .join(' · ') +} diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 662e89a..8d2306c 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -393,6 +393,31 @@ }, subtaskCreated: 'Subtask created — it went to the stack for detailing', createdRaw: 'Task went to the stack — detail it there', + // Prompt for an AI agent (0.87): the task as text, copied and handed to the + // owner's agent. Facts only — what to do with the task is the owner's call + ai: { + menu: 'Prompt for AI', + title: 'Prompt for an AI agent', + hint: 'The task as text to hand to an AI agent. Edit it if needed, then copy.', + copy: 'Copy prompt', + close: 'Close', + intro: 'A task from GNexus Tasks.', + taskSection: 'Task', + descriptionSection: 'Description', + proposalSection: 'Detailing (AI proposal, not approved)', + subtasksSection: 'Subtasks', + linkSection: 'Link', + id: 'ID', + titleField: 'Title', + status: 'Status', + tags: 'Tags', + parentTask: 'Parent task', + eligible: 'Available to an AI agent', + eligibleYes: 'yes', + eligibleNo: 'no', + claimed: 'Claimed', + descriptionEmpty: 'No description.', + }, actions: 'Actions', addTag: 'Add a tag…', // Проект задачи в меню карточки (0.82): перейти к проекту и перенести diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index fd2b2d7..a141abb 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -395,6 +395,32 @@ }, subtaskCreated: 'Подзадача создана — попала в стек на детализацию', createdRaw: 'Задача попала в стек — детализируйте её там', + // Промпт для ИИ-агента (0.87): текст задачи, который владелец копирует и + // передаёт своему агенту. Только факты о задаче — что с ней делать, решает + // владелец, а не кнопка + ai: { + menu: 'Промпт для ИИ', + title: 'Промпт для ИИ-агента', + hint: 'Текст задачи для передачи ИИ-агенту. При необходимости поправьте и скопируйте.', + copy: 'Скопировать промпт', + close: 'Закрыть', + intro: 'Задача из GNexus Tasks.', + taskSection: 'Задача', + descriptionSection: 'Описание', + proposalSection: 'Детализация (предложение ИИ, не утверждено)', + subtasksSection: 'Подзадачи', + linkSection: 'Ссылка', + id: 'ID', + titleField: 'Заголовок', + status: 'Статус', + tags: 'Теги', + parentTask: 'Родительская задача', + eligible: 'Доступно для ИИ-агента', + eligibleYes: 'да', + eligibleNo: 'нет', + claimed: 'Взято в работу', + descriptionEmpty: 'Описание пусто.', + }, actions: 'Действия', addTag: 'Добавить тег…', // Проект задачи в меню карточки (0.82): перейти к проекту и перенести diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index ba8c305..ca92ff0 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -393,6 +393,31 @@ }, subtaskCreated: 'Підзадачу створено — потрапила у стек на деталізацію', createdRaw: 'Завдання потрапило у стек — деталізуйте його там', + // Промпт для ШІ-агента (0.87): текст завдання, який власник копіює та передає + // своєму агенту. Лише факти про завдання — що з ним робити, вирішує власник + ai: { + menu: 'Промпт для ШІ', + title: 'Промпт для ШІ-агента', + hint: 'Текст завдання для передачі ШІ-агенту. За потреби відредагуйте та скопіюйте.', + copy: 'Копіювати промпт', + close: 'Закрити', + intro: 'Завдання з GNexus Tasks.', + taskSection: 'Завдання', + descriptionSection: 'Опис', + proposalSection: 'Деталізація (пропозиція ШІ, не затверджено)', + subtasksSection: 'Підзадачі', + linkSection: 'Посилання', + id: 'ID', + titleField: 'Заголовок', + status: 'Статус', + tags: 'Теги', + parentTask: 'Батьківське завдання', + eligible: 'Доступне для ШІ-агента', + eligibleYes: 'так', + eligibleNo: 'ні', + claimed: 'Взято в роботу', + descriptionEmpty: 'Опис порожній.', + }, actions: 'Дії', addTag: 'Додати теґ…', // Проєкт задачі в меню картки (0.82): перейти до проєкту й перенести diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue index b9368c4..a3a5c8a 100644 --- a/frontend/src/views/TaskView.vue +++ b/frontend/src/views/TaskView.vue @@ -28,6 +28,7 @@ import { celebrateEarned } from '../gamification' import { onRealtime } from '../realtime' import { isMobile } from '../viewport' +import { buildTaskPrompt } from '../aiPrompt' import InlineField from '../components/InlineField.vue' import ConfirmDialog from '../components/ConfirmDialog.vue' import TagField from '../components/TagField.vue' @@ -60,6 +61,11 @@ const editing = ref(false) const notFound = ref(false) +// Промпт для ИИ-агента (0.87): текст собирается по открытию окна, чтобы показать +// задачу в том виде, в каком она сейчас на странице +const aiPromptOpen = ref(false) +const aiPromptText = ref('') + // Чекбоксы списков задач в отображаемом описании кликабельны: marked рендерит // их с disabled, а клики по disabled-инпутам браузер не шлёт вовсе — атрибут // снимаем, статус сохраняет onDescClick (PATCH сырого markdown) @@ -444,11 +450,32 @@ if (!task0) return [] return [ { label: t('common.edit'), icon: 'ph-pencil-simple', onSelect: () => (editing.value = true) }, + { label: t('task.ai.menu'), icon: 'ph-robot', onSelect: openAiPrompt }, { label: t('stack.redetail'), icon: 'ph-arrows-counter-clockwise', onSelect: redetail }, { label: t('common.delete'), icon: 'ph-trash', danger: true, onSelect: () => askDelete(task0) }, ] } +// «Промпт для ИИ-агента» (0.87): задача текстом — владелец вставляет его в чат со +// своим агентом. Текст собирается по открытию окна, а не один раз при загрузке +// страницы: пока окно открыто, задача успевает измениться (SSE), а в буфере должен +// оказаться её текущий вид. +function openAiPrompt() { + const current = task.value + if (!current) return + aiPromptText.value = buildTaskPrompt( + current, + { + subtasks: childTasks.value, + parent: parentTask.value, + lease: leaseLine.value, + url: `${window.location.origin}/tasks/${current.id}`, + }, + t, + ) + aiPromptOpen.value = true +} + async function setStatus(_task: Task, status: string) { error.value = '' try { @@ -1249,6 +1276,22 @@ @confirm="remove" /> + + +
+

{{ t('task.ai.hint') }}

+ +
+ +
+ @@ -1693,4 +1736,32 @@ .subtask-list { margin-top: 1.5rem; } +/* Промпт для ИИ-агента: текст читают и правят перед отправкой — моноширинный + (как code-блоки кита) и на всю высоту окна, чтобы видеть задачу целиком */ +.ai-prompt { + display: flex; + flex-direction: column; + gap: 12px; +} +.ai-prompt-hint { + margin: 0; + font-size: 12px; + opacity: 0.6; +} +/* у .form-group кита max-width: 600px — промпту нужна вся ширина окна */ +.ai-prompt :deep(.form-group) { + max-width: none; +} +/* кит держит textarea в 92px (`.form-group .label textarea.input`), а промпт — это + несколько экранов текста: снимаем высоту тем же селектором, что и MdEditor, иначе + правило кита перебивает наше по специфичности */ +.ai-prompt :deep(textarea.input) { + width: 100%; + height: auto; + min-height: 420px; + font-size: 12px; + font-family: 'IBM Plex Mono', monospace; + line-height: 1.5; + resize: vertical; +} \ No newline at end of file