AI-чат: прервать агента сообщением, сохранив частичный вывод («отправить прям щас») #198
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Задача
Дать пользователю возможность прервать работающего агента и тут же отправить ему отложенное (queued) сообщение, при этом:
Как это устроено сейчас (результат исследования)
Клиент — очередь «отправить, пока агент занят». В
apps/client/src/features/ai-chat/components/chat-thread.tsxесть локальная FIFO-очередь (queued/queuedRef, помощники вapps/client/src/features/ai-chat/utils/queue-helpers.ts). Пока идёт стрим, ввод вchat-input.tsxне отправляет, а кладёт текст в очередь (onQueue). Очередь «сливается» по одному сообщению вonFinish— только при чистом завершении; приStop/обрыве/ошибке (isAbort || isDisconnect || isError)flushNext()намеренно не вызывается, очередь сохраняется. Очередь рисуется списком с иконкой-часами и кнопкой-крестиком. Кнопки «отправить сейчас» сейчас нет.Сервер — частичный вывод уже сохраняется. В
apps/server/src/core/ai-chat/ai-chat.service.tsреализована пошаговая устойчивость (#183): строка ассистента создаётся заранее в статусеstreaming, обновляется по мере завершения каждого шага и финализируется один раз. При прерывании срабатываетonAbort, который сохраняет частичный ответ (завершённые шаги + текст текущего шага) со статусомabortedиfinishReason: 'aborted'. Колонкаstatus(streaming|completed|error|aborted) добавлена миграцией20260626T120000-ai-chat-message-status.ts.Сервер — история восстанавливается из БД, независимо от статуса. Каждый ход модель видит не клиентский payload, а серверную историю:
findRecent(...)грузит последние 50 сообщений без фильтра поstatus(apps/server/src/database/repos/ai-chat/ai-chat-message.repo.ts), затемconvertToModelMessages. Прерванная (aborted) строка ассистента с её частичнымиmetadata.partsпопадёт в контекст следующего хода автоматически. Незавершённые tool-call'ы прерванного шага вcapturedStepsне попадают (туда кладут только завершённые шаги вonStepFinish), поэтому рискаMissingToolResultsErrorнет; завершённые, но беспарные вызовыassistantPartsзакрывает синтетическимoutput-error.Вывод из исследования
Бóльшая часть требуемого уже есть. Частичный вывод сохраняется (
onAbort→aborted) и реплеится модели (findRecentбез фильтра по статусу). Не хватает ровно двух вещей:Архитектура решения
Поток по шагам (пользователь нажал «отправить прям щас» на отложенном сообщении X):
Ключевая идея — переиспользовать существующий механизм очереди и слива: «отправить сейчас» = «поставить X в голову очереди + прервать ход + слить голову на
onAbortвместо игнорирования». Серверная пометка — по образцу уже имеющегосяFINAL_STEP_INSTRUCTION, который конкатенируется к системному промпту.Клиент
A.
queue-helpers.ts— новый чистый помощник (в стиле существующих, с юнит-тестами):B.
chat-thread.tsx— логика «отправить сейчас»:В
onFinish— добавить ветку слива при «нашем» abort'е (раньше там былreturn):В
prepareSendMessagesRequest— read-and-clear флага, чтобы пометку нёс только этот запрос:C. Кнопка в списке очереди — добавить
ActionIconрядом с крестиком:Строки
t("Send now")/t("Interrupt and send now")— английский исходник как ключ (i18n здесь Crowdin-managed, отдельный JSON править не нужно).Сервер
A.
ai-chat.service.ts:AiChatStreamBodyдобавитьinterrupted?: boolean.stream()после загрузкиhistoryвычислить факт прерывания и подтвердить его историей (защита от ложного/подменённого флага):interruptedвbuildSystemPrompt({ ... , interrupted }).B.
ai-chat.prompt.ts: добавить поле вBuildSystemPromptInputи врезку в секциюcontext(внутри safety-сэндвича, перед завершающимSAFETY_FRAMEWORK):Частичный вывод модели подавать не нужно отдельно — он уже в истории (
findRecent→aborted-строка сmetadata.parts).INTERRUPT_NOTE— это и есть «комментарий, что пользователь прервал».Краевые случаи (учтены в дизайне)
onAbort↔ загрузка истории нового хода. Новый POST может прийти раньше, чемonAbortфинализирует строку вaborted. Не критично: благодаря #183 строка ассистента существует заранее (streaming) и обновляется по шагам, аfindRecentстатус не фильтрует — частичный вывод всегда доступен; в худшем случае не успеет попасть лишь последний фрагмент текущего шага (несколько токенов). Поэтому в условие включён и статусstreaming.isStreaming === false→ ветка else: просто шлём сообщение, без abort и без пометки.flushNextна чистомonFinish).flushOnAbortRef/interruptNextSendRef— однократные (read-and-clear); повторный клик до оседания хода переустановит голову очереди, лишнего слива не будет.capturedSteps, поэтому история остаётся валидной; завершённые беспарные вызовы уже закрываютсяoutput-errorвassistantParts.stopNoticeв веткеflushOnAbortRef, чтобы не моргало перед стартом нового хода.interruptedподтверждается серверной историей (предыдущий ход реальноaborted/streaming), иначе игнорируется — подменённый флаг на обычном ходе ничего не инжектит.INTERRUPT_NOTE— на английском (как весь системный промпт); модель отвечает на языке пользователя по уже имеющейся инструкции.aria-label+ tooltip; строки черезt(...).Принятые решения и альтернативы
FINAL_STEP_INSTRUCTION) — кросс-провайдерно безопасно (z.ai/OpenAI/Anthropic и т.д.), минимально инвазивно, само исчезает на следующем ходу (флаг ставится только для interrupt-хода). Альтернатива — врезать note префиксом в модельное представление user-сообщения (локальнее «по месту», но требует возни с формойModelMessage.content). Обе эквивалентны по эффекту; рекомендуется первая.aborted(покрыло бы и «Stop → потом дописал»). Но это даёт ложные срабатывания на несвязанном сообщении много позже. Поэтому: флаг от кнопки + подтверждение историей. Это точно соответствует формулировке «прервал его сообщением».План реализации (декомпозиция)
queue-helpers.ts:promoteToHead+ юнит-тесты вqueue-helpers.test.ts.chat-thread.tsx: рефыflushOnAbortRef/interruptNextSendRef,sendNow, ветка слива вonFinish,interruptedвprepareSendMessagesRequest, кнопка в списке очереди, пробросsendNow.interruptedвAiChatStreamBody; вычисление+подтверждениеinterruptedвstream(); проброс вbuildSystemPrompt.ai-chat.prompt.ts:interruptedвBuildSystemPromptInput+INTERRUPT_NOTE.ai-chat.prompt.spec.ts(note есть приinterrupted:true, нет приfalse);ai-chat.service.spec.ts(флаг без aborted-предшественника → note не инжектится; с aborted/streaming → инжектится); опционально — клиентский тест наpromoteToHeadи формированиеbody.interrupted.Затронутые файлы
apps/client/src/features/ai-chat/utils/queue-helpers.ts(+.test.ts)apps/client/src/features/ai-chat/components/chat-thread.tsxapps/server/src/core/ai-chat/ai-chat.service.tsapps/server/src/core/ai-chat/ai-chat.prompt.ts(+.spec.ts)apps/server/src/core/ai-chat/ai-chat.service.spec.tsvvzvlad referenced this issue2026-06-25 23:58:10 +03:00
Ghost referenced this issue2026-06-26 00:16:40 +03:00
Ghost referenced this issue2026-06-26 17:39:56 +03:00
Ghost referenced this issue2026-06-27 21:24:34 +03:00
Ghost referenced this issue2026-06-28 04:23:39 +03:00
Ghost referenced this issue2026-06-28 22:18:11 +03:00