AI-чат: показывать текст запроса/аргументы в карточке вызова инструмента #392

Closed
opened 2026-07-06 21:15:46 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема

В панели AI-агента каждый вызов инструмента отображается карточкой-логом (ToolCallCard). Сейчас карточка показывает только человекочитаемую метку действия. Для инструментов без специальной метки (в частности — внешние MCP-инструменты вроде Search_web_search, Search_read_pages) карточка выводит родовую надпись «Ran tool Search_web_search» и не показывает, что именно искали.

На скриншоте от пользователя видна длинная серия одинаковых карточек Ran tool Search_web_search — по ним невозможно понять, какие запросы делал агент. Хочется, чтобы в карточке показывался текст запроса (аргументы вызова).

Как устроено сейчас

  • Рендер карточки: apps/client/src/features/ai-chat/components/tool-call-card.tsx. Выводится только t(key, values) — метка, полученная из toolLabelKey(toolName).
  • Метки: apps/client/src/features/ai-chat/utils/tool-parts.tsx, функция toolLabelKey(). Известные Docmost-инструменты получают дружелюбные метки («Searched pages», «Created page» и т.д.), всё остальное падает в default"Ran tool {{name}}". Внешние MCP-инструменты всегда попадают в этот default.
  • Аргументы уже доступны: они лежат в part.input (объект). Например, для web-поиска это { query: "..." }; для Docmost searchPages{ query }; для операций со страницей — { pageId, title, ... }.
  • Данные переживают перезагрузку истории: сервер сохраняет полный parts (включая input) в metadata.parts. Более того, экспорт в Markdown (apps/server/src/core/ai-chat/chat-markdown.util.ts, строки ~209–211) уже рендерит part.input как fenced-блок JSON. То есть прецедент показа входных данных есть, и input доступен и в лайве, и в открытой заново истории.

Предлагаемое решение

Добавить рядом с меткой компактную строку-саммари аргументов (одна строка, dimmed, с обрезкой по длине).

1. Хелпер toolInputSummary(part) в tool-parts.tsx

Чистая функция, извлекающая короткую человекочитаемую строку из part.input:

/**
 * A short one-line summary of a tool call's arguments for the action-log card,
 * or undefined when there is nothing worth showing. Prefers a known "primary"
 * string field (search query, url, title, ...); the value is rendered as PLAIN
 * TEXT (React-escaped) — never as markdown/HTML.
 */
export function toolInputSummary(part: ToolUiPart): string | undefined {
  // Only show once the input is finalized. During `input-streaming` the input
  // object grows while `state` stays "input-streaming", and messageSignature
  // does NOT track `input` — so a live-growing summary would freeze/stale (see
  // the warning in message-signature.ts). Gating on the finalized states means
  // the state flip (input-streaming -> input-available) already re-renders the
  // row with the complete value, with zero changes to messageSignature.
  if (part.state === "input-streaming") return undefined;

  const input = part.input;
  if (!input || typeof input !== "object") return undefined;
  const rec = input as Record<string, unknown>;

  // Priority order of "primary" fields across our Docmost tools + common MCP
  // tools (web_search -> query, read_pages -> url/urls, page ops -> title).
  const PRIMARY = ["query", "q", "searchQuery", "url", "title", "name", "text", "prompt"];
  for (const key of PRIMARY) {
    const v = rec[key];
    if (typeof v === "string" && v.trim()) return clamp(collapse(v));
    if (Array.isArray(v) && v.length > 0 && typeof v[0] === "string") {
      const extra = v.length > 1 ? ` (+${v.length - 1})` : "";
      return clamp(collapse(String(v[0])) + extra);
    }
  }
  return undefined; // no recognizable field -> show nothing (avoid JSON noise)
}

где collapse схлопывает пробелы/переводы строк в один пробел, clamp обрезает до ~140 символов с «…».

Открытый вопрос (см. ниже) — нужен ли фолбэк на компактный JSON-превью, когда primary-поле не найдено. Дефолт предложения: не показывать (меньше шума), т.к. у известных инструментов primary-поле обычно есть.

2. Рендер в tool-call-card.tsx

Под строкой с меткой добавить (когда саммари непустое):

const inputSummary = toolInputSummary(part);
// ...
{inputSummary && (
  <Text size="xs" c="dimmed" mt={2} lineClamp={2}>
    {inputSummary}
  </Text>
)}

Стиль — как у блока цитат: size="xs", c="dimmed", обрезка строк. Рендер через Mantine <Text> (React экранирует) — никакого dangerouslySetInnerHTML, поэтому XSS невозможен, даже если запрос содержит HTML/markdown.

Файлы к изменению

  • apps/client/src/features/ai-chat/utils/tool-parts.tsx — новый хелпер toolInputSummary (+ collapse/clamp).
  • apps/client/src/features/ai-chat/components/tool-call-card.tsx — рендер саммари под меткой.
  • apps/client/src/features/ai-chat/utils/tool-parts.test.tsx — юнит-тесты хелпера.
  • (опционально) apps/client/src/features/ai-chat/components/ai-chat.module.css — если понадобится отдельный класс.
  • Сервер (chat-markdown.util.ts) менять не нужно — экспорт уже показывает input.

Тонкие места (обязательно учесть)

  1. Мемоизация / messageSignature. message-signature.ts НЕ включает input в сигнатуру строки, а MessageItem мемоизирован по ней. Во время input-streaming объект input растёт, но state не меняется → строка не перерендерится. Поэтому саммари показываем только после финализации входа (гейт state !== "input-streaming" в хелпере): переход input-streaming → input-available меняет state, а значит и сигнатуру, → строка перерисуется с полным значением. Так мы избегаем «замороженной» строки без правки load-bearing функции messageSignature. Альтернатива (если захотим показывать запрос живьём по мере стриминга) — расширить messageSignature сигналом от input (например, длиной JSON.stringify(input)); это дороже и трогает критичную функцию, поэтому не рекомендуется для v1.
  2. Экранирование / XSS. Только plain text через <Text>. Не интерпретировать markdown/HTML.
  3. Длина. Жёстко обрезать (≈140 символов) и схлопывать переводы строк — длинные text/prompt не должны ломать вёрстку карточки.
  4. i18n. Само саммари — это пользовательские данные (запрос), их не переводим, поэтому новый ключ перевода не требуется (никаких обрамляющих слов не добавляем). Побочно: в ru-RU/translation.json уже отсутствуют ключи меток инструментов (Ran tool {{name}}, Searched pages и др.) — есть только Open page, поэтому русскоязычные пользователи и сейчас видят английские метки. Можно заодно добавить недостающие RU-ключи, но это отдельный мелкий фикс, не блокирующий эту задачу.

Крайние случаи

  • input отсутствует / не объект / пустой → саммари undefined, ничего не рендерим.
  • Массив (urls) → показываем первый элемент + (+N).
  • Инструмент без распознаваемого primary-поля → ничего не показываем (по дефолту).
  • Persisted-история и публичный шэр: input доступен и там; проверить оба сценария (см. вопрос про шэр ниже).

Тесты

Добавить в tool-parts.test.tsx покрытие toolInputSummary:

  • query → строка запроса;
  • массив urls → первый + (+N);
  • фолбэк на title для операции со страницей;
  • обрезка длинного значения и схлопывание пробелов;
  • нет input / пустой / не-объект → undefined;
  • state: "input-streaming"undefined (не показываем недостроенный ввод).

Вопросы к решению (перед реализацией)

  1. Публичный шэр. Карточка используется и в публичном виджете (там showCitations={false}). Показывать ли текст запроса анонимному читателю? Варианты: (а) показывать всегда — это часть лога действий агента; (б) скрывать по аналогии с цитатами (новый проп showInput, по умолчанию true, в шэре false). Предлагаю (а), но нужно подтверждение — вдруг запросы могут содержать чувствительный контекст.
  2. Фолбэк-превью. Показывать ли компактный JSON, когда primary-поля нет? Предлагаю не показывать (меньше шума).

🤖 Оформлено при участии Claude Code (архитектурный разбор кода; код не менялся).

## Проблема В панели AI-агента каждый вызов инструмента отображается карточкой-логом (`ToolCallCard`). Сейчас карточка показывает только человекочитаемую метку действия. Для инструментов без специальной метки (в частности — внешние MCP-инструменты вроде `Search_web_search`, `Search_read_pages`) карточка выводит родовую надпись **«Ran tool Search_web_search»** и не показывает, *что именно* искали. На скриншоте от пользователя видна длинная серия одинаковых карточек `Ran tool Search_web_search` — по ним невозможно понять, какие запросы делал агент. Хочется, чтобы в карточке показывался текст запроса (аргументы вызова). ## Как устроено сейчас - Рендер карточки: `apps/client/src/features/ai-chat/components/tool-call-card.tsx`. Выводится только `t(key, values)` — метка, полученная из `toolLabelKey(toolName)`. - Метки: `apps/client/src/features/ai-chat/utils/tool-parts.tsx`, функция `toolLabelKey()`. Известные Docmost-инструменты получают дружелюбные метки («Searched pages», «Created page» и т.д.), всё остальное падает в `default` → `"Ran tool {{name}}"`. Внешние MCP-инструменты всегда попадают в этот `default`. - **Аргументы уже доступны**: они лежат в `part.input` (объект). Например, для web-поиска это `{ query: "..." }`; для Docmost `searchPages` — `{ query }`; для операций со страницей — `{ pageId, title, ... }`. - **Данные переживают перезагрузку истории**: сервер сохраняет полный `parts` (включая `input`) в `metadata.parts`. Более того, экспорт в Markdown (`apps/server/src/core/ai-chat/chat-markdown.util.ts`, строки ~209–211) **уже** рендерит `part.input` как fenced-блок JSON. То есть прецедент показа входных данных есть, и `input` доступен и в лайве, и в открытой заново истории. ## Предлагаемое решение Добавить рядом с меткой компактную строку-саммари аргументов (одна строка, dimmed, с обрезкой по длине). ### 1. Хелпер `toolInputSummary(part)` в `tool-parts.tsx` Чистая функция, извлекающая короткую человекочитаемую строку из `part.input`: ```ts /** * A short one-line summary of a tool call's arguments for the action-log card, * or undefined when there is nothing worth showing. Prefers a known "primary" * string field (search query, url, title, ...); the value is rendered as PLAIN * TEXT (React-escaped) — never as markdown/HTML. */ export function toolInputSummary(part: ToolUiPart): string | undefined { // Only show once the input is finalized. During `input-streaming` the input // object grows while `state` stays "input-streaming", and messageSignature // does NOT track `input` — so a live-growing summary would freeze/stale (see // the warning in message-signature.ts). Gating on the finalized states means // the state flip (input-streaming -> input-available) already re-renders the // row with the complete value, with zero changes to messageSignature. if (part.state === "input-streaming") return undefined; const input = part.input; if (!input || typeof input !== "object") return undefined; const rec = input as Record<string, unknown>; // Priority order of "primary" fields across our Docmost tools + common MCP // tools (web_search -> query, read_pages -> url/urls, page ops -> title). const PRIMARY = ["query", "q", "searchQuery", "url", "title", "name", "text", "prompt"]; for (const key of PRIMARY) { const v = rec[key]; if (typeof v === "string" && v.trim()) return clamp(collapse(v)); if (Array.isArray(v) && v.length > 0 && typeof v[0] === "string") { const extra = v.length > 1 ? ` (+${v.length - 1})` : ""; return clamp(collapse(String(v[0])) + extra); } } return undefined; // no recognizable field -> show nothing (avoid JSON noise) } ``` где `collapse` схлопывает пробелы/переводы строк в один пробел, `clamp` обрезает до ~140 символов с «…». Открытый вопрос (см. ниже) — нужен ли фолбэк на компактный JSON-превью, когда primary-поле не найдено. Дефолт предложения: не показывать (меньше шума), т.к. у известных инструментов primary-поле обычно есть. ### 2. Рендер в `tool-call-card.tsx` Под строкой с меткой добавить (когда саммари непустое): ```tsx const inputSummary = toolInputSummary(part); // ... {inputSummary && ( <Text size="xs" c="dimmed" mt={2} lineClamp={2}> {inputSummary} </Text> )} ``` Стиль — как у блока цитат: `size="xs"`, `c="dimmed"`, обрезка строк. Рендер через Mantine `<Text>` (React экранирует) — **никакого** `dangerouslySetInnerHTML`, поэтому XSS невозможен, даже если запрос содержит HTML/markdown. ## Файлы к изменению - `apps/client/src/features/ai-chat/utils/tool-parts.tsx` — новый хелпер `toolInputSummary` (+ `collapse`/`clamp`). - `apps/client/src/features/ai-chat/components/tool-call-card.tsx` — рендер саммари под меткой. - `apps/client/src/features/ai-chat/utils/tool-parts.test.tsx` — юнит-тесты хелпера. - (опционально) `apps/client/src/features/ai-chat/components/ai-chat.module.css` — если понадобится отдельный класс. - Сервер (`chat-markdown.util.ts`) менять не нужно — экспорт уже показывает `input`. ## Тонкие места (обязательно учесть) 1. **Мемоизация / `messageSignature`.** `message-signature.ts` НЕ включает `input` в сигнатуру строки, а `MessageItem` мемоизирован по ней. Во время `input-streaming` объект `input` растёт, но `state` не меняется → строка не перерендерится. Поэтому саммари показываем только после финализации входа (гейт `state !== "input-streaming"` в хелпере): переход `input-streaming → input-available` меняет `state`, а значит и сигнатуру, → строка перерисуется с полным значением. Так мы избегаем «замороженной» строки **без** правки load-bearing функции `messageSignature`. Альтернатива (если захотим показывать запрос живьём по мере стриминга) — расширить `messageSignature` сигналом от `input` (например, длиной `JSON.stringify(input)`); это дороже и трогает критичную функцию, поэтому не рекомендуется для v1. 2. **Экранирование / XSS.** Только plain text через `<Text>`. Не интерпретировать markdown/HTML. 3. **Длина.** Жёстко обрезать (≈140 символов) и схлопывать переводы строк — длинные `text`/`prompt` не должны ломать вёрстку карточки. 4. **i18n.** Само саммари — это пользовательские данные (запрос), их не переводим, поэтому новый ключ перевода не требуется (никаких обрамляющих слов не добавляем). Побочно: в `ru-RU/translation.json` уже отсутствуют ключи меток инструментов (`Ran tool {{name}}`, `Searched pages` и др.) — есть только `Open page`, поэтому русскоязычные пользователи и сейчас видят английские метки. Можно заодно добавить недостающие RU-ключи, но это отдельный мелкий фикс, не блокирующий эту задачу. ## Крайние случаи - `input` отсутствует / не объект / пустой → саммари `undefined`, ничего не рендерим. - Массив (`urls`) → показываем первый элемент + `(+N)`. - Инструмент без распознаваемого primary-поля → ничего не показываем (по дефолту). - Persisted-история и публичный шэр: `input` доступен и там; проверить оба сценария (см. вопрос про шэр ниже). ## Тесты Добавить в `tool-parts.test.tsx` покрытие `toolInputSummary`: - `query` → строка запроса; - массив `urls` → первый + `(+N)`; - фолбэк на `title` для операции со страницей; - обрезка длинного значения и схлопывание пробелов; - нет `input` / пустой / не-объект → `undefined`; - `state: "input-streaming"` → `undefined` (не показываем недостроенный ввод). ## Вопросы к решению (перед реализацией) 1. **Публичный шэр.** Карточка используется и в публичном виджете (там `showCitations={false}`). Показывать ли текст запроса анонимному читателю? Варианты: (а) показывать всегда — это часть лога действий агента; (б) скрывать по аналогии с цитатами (новый проп `showInput`, по умолчанию `true`, в шэре `false`). Предлагаю (а), но нужно подтверждение — вдруг запросы могут содержать чувствительный контекст. 2. **Фолбэк-превью.** Показывать ли компактный JSON, когда primary-поля нет? Предлагаю не показывать (меньше шума). --- 🤖 Оформлено при участии Claude Code (архитектурный разбор кода; код не менялся).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#392