feat(frontend): allow chat replies during clarification (#4530)

* feat(frontend): allow chat replies during clarification

* fix(frontend): unlock input polish during clarification

Remove hasOpenHumanInputCard from inputPolishDisabled so the polish
button stays available when a clarification card is open, matching
the composer unlock behavior. Clean up the now-unused useMemo and
import.
This commit is contained in:
qin-chenghan 2026-07-28 22:19:50 +08:00 committed by GitHub
parent e47bf80122
commit 1bccc8e20e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 16 additions and 48 deletions

View File

@ -783,6 +783,8 @@ Gateway-generated follow-up suggestions now normalize both plain-string model ou
The Web UI composer can polish draft input before sending. The rewrite runs as a short Gateway LLM request using the `input_polish` model configuration, keeps slash skill prefixes such as `/data-analysis`, and only replaces the local draft after the user clicks the polish button; it does not create a thread run or persist a message.
When the agent asks for clarification, the Web UI shows the structured response card but keeps the normal composer available. Users can complete the card or send a free-form chat message to bypass it; that message closes the latest pending clarification and becomes the agent's next input.
Unsent Web UI composer drafts survive page reloads and switching between conversations within the same browser tab. Drafts are isolated by user, agent, and conversation, include a selected slash skill when present, and are cleared once a send is accepted. Attachments and quoted conversation context are intentionally not persisted.
The Web UI composer also supports browser-based voice dictation when the browser exposes the Web Speech API. The microphone button transcribes speech into the local draft only; DeerFlow receives only the transcribed text, while audio handling is delegated to the browser or operating system speech-recognition service according to that environment's policy. Users can review or edit the text before sending.

View File

@ -81,7 +81,7 @@ Auth UI note: the login page's "keep me signed in" option submits only `remember
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. The protocol is versioned on the request side only: v1 covers `free_text` / `choice_with_other`, and v2 adds `form` (typed fields — text/textarea/number/select/multi_select/checkbox/date — with required-field validation in the card). Replies deliberately stay on the v1 response protocol: the form card submits a `response_kind: "text"` reply whose value is the human-readable summary plus one JSON block keyed by stable field names (`buildHumanInputFormSubmissionValue` — the readable part alone is ambiguous because labels/values may contain the separators), so the model can reconstruct the submitted mapping without a structured response kind. The validators reject unknown versions/modes (and field names colliding with JS `Object.prototype` members) so future protocol bumps degrade to the plain-text ToolMessage fallback rather than rendering a broken card. Form values are read through own-property access only (`readHumanInputFormValue`); select fields stay controlled from their empty-string placeholder state through selection; checkbox fields are native `<input type="checkbox">` controls seeded to an explicit `false` (`buildInitialHumanInputFormValues`) so an untouched checkbox submits as "no" while a `required` checkbox keeps must-agree semantics (no HTML `required` attribute — native constraint validation would intercept the custom submit path), and form controls carry label/`htmlFor`, `aria-required` plus a visually-hidden localized "required" marker, and `aria-invalid`/error associations whose error node stays mounted while any field is still invalid. Legacy-fallback closure: `deriveHumanInputThreadState` treats a visible plain human message as answering the latest unanswered request opened before it (only the latest — nothing guarantees a single outstanding request across runs, and closing all would silently swallow older decisions; an older request left open simply becomes the active card again) — an old v1-only frontend degrades a v2 request to text and the user replies through the normal composer without response metadata, and without this rule an upgraded frontend would see that request as still open and lock the composer. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points should disable normal bottom input while `hasOpenHumanInputRequest(...)` is true so users answer through the card and preserve response metadata.
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. The protocol is versioned on the request side only: v1 covers `free_text` / `choice_with_other`, and v2 adds `form` (typed fields — text/textarea/number/select/multi_select/checkbox/date — with required-field validation in the card). Replies deliberately stay on the v1 response protocol: the form card submits a `response_kind: "text"` reply whose value is the human-readable summary plus one JSON block keyed by stable field names (`buildHumanInputFormSubmissionValue` — the readable part alone is ambiguous because labels/values may contain the separators), so the model can reconstruct the submitted mapping without a structured response kind. The validators reject unknown versions/modes (and field names colliding with JS `Object.prototype` members) so future protocol bumps degrade to the plain-text ToolMessage fallback rather than rendering a broken card. Form values are read through own-property access only (`readHumanInputFormValue`); select fields stay controlled from their empty-string placeholder state through selection; checkbox fields are native `<input type="checkbox">` controls seeded to an explicit `false` (`buildInitialHumanInputFormValues`) so an untouched checkbox submits as "no" while a `required` checkbox keeps must-agree semantics (no HTML `required` attribute — native constraint validation would intercept the custom submit path), and form controls carry label/`htmlFor`, `aria-required` plus a visually-hidden localized "required" marker, and `aria-invalid`/error associations whose error node stays mounted while any field is still invalid. Composer-bypass closure: `deriveHumanInputThreadState` treats a visible plain human message as answering the latest unanswered request opened before it (only the latest — nothing guarantees a single outstanding request across runs, and closing all would silently swallow older decisions; an older request left open simply becomes the active card again). This lets current users bypass a structured form through the normal composer and preserves compatibility with old v1-only frontends that degrade a v2 request to plain text. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level card submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points remain enabled while a human-input request is open; a normal visible message intentionally bypasses the card and starts the next run without structured response metadata.
Tool-calling AI messages can contain user-visible text as well as `tool_calls`. `core/messages/utils.ts` keeps these turns in an `assistant:processing` group, and `components/workspace/messages/message-group.tsx` must render the visible text as a processing step instead of treating the message as only tool metadata. This preserves provider text such as error explanations or "trying another approach" notes during tool-heavy runs.
While the current turn is still loading, a content-only AI message after the latest visible human input also stays in that processing group until the turn settles: a provider may append tool-call chunks to the same message later, and classifying it as a final assistant bubble too early makes the text jump into the steps panel. `MessageGroup` therefore renders processing text even before the first tool call arrives.

View File

@ -401,7 +401,6 @@ export default function AgentChatPage() {
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
hasOpenHumanInputCard ||
(!isNewThread && isHistoryLoading)
}
onContextChange={(context) =>

View File

@ -40,11 +40,9 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import {
buildHumanInputResponseText,
hasOpenHumanInputRequest,
type HumanInputRequest,
type HumanInputResponse,
} from "@/core/messages/human-input";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { safeLocalStorage } from "@/core/settings/local";
import { hasToolResult, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
@ -119,15 +117,6 @@ export default function NewAgentPage() {
});
},
});
const hasOpenHumanInputCard = useMemo(
() =>
hasOpenHumanInputRequest(
thread.messages,
(message) => !isHiddenFromUIMessage(message),
),
[thread.messages],
);
useEffect(() => {
if (typeof window === "undefined" || step !== "chat") {
return;
@ -225,14 +214,14 @@ export default function NewAgentPage() {
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading || hasOpenHumanInputCard) return;
if (!trimmed || thread.isLoading) return;
await sendMessage(
threadId,
{ text: trimmed, files: [] },
{ agent_name: agentName },
);
},
[agentName, hasOpenHumanInputCard, sendMessage, thread.isLoading, threadId],
[agentName, sendMessage, thread.isLoading, threadId],
);
const handleSubmitHumanInput = useCallback(
@ -443,18 +432,16 @@ export default function NewAgentPage() {
</div>
) : (
<PromptInput
disabled={thread.isLoading || hasOpenHumanInputCard}
disabled={thread.isLoading}
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
autoFocus
placeholder={t.agents.createPageSubtitle}
disabled={thread.isLoading || hasOpenHumanInputCard}
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit
disabled={thread.isLoading || hasOpenHumanInputCard}
/>
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
)}

View File

@ -419,7 +419,6 @@ export default function ChatPage() {
isMock ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
hasOpenHumanInputCard ||
(!isNewThread && isHistoryLoading)
}
onContextChange={(context) =>

View File

@ -73,7 +73,6 @@ import { useAuth } from "@/core/auth/AuthProvider";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { polishInputDraft } from "@/core/input-polish/api";
import { hasOpenHumanInputRequest } from "@/core/messages/human-input";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
@ -1304,14 +1303,6 @@ export function InputBox({
dismissedSkillSuggestionValue !== textInput.value;
const isComposerDisabled = disabled === true;
const isMockThread = isMock === true;
const hasOpenHumanInputCard = useMemo(
() =>
hasOpenHumanInputRequest(
thread.messages,
(message) => !isHiddenFromUIMessage(message),
),
[thread.messages],
);
const composerLocked = isComposerDisabled || polishingInput;
const inputPolishUndoAvailable =
!polishingInput &&
@ -1320,7 +1311,6 @@ export function InputBox({
const inputPolishDisabled =
isComposerDisabled ||
isMockThread ||
hasOpenHumanInputCard ||
polishingInput ||
(!inputPolishUndoAvailable &&
(status === "streaming" ||

View File

@ -51,11 +51,9 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import {
buildHumanInputResponseText,
hasOpenHumanInputRequest,
type HumanInputRequest,
type HumanInputResponse,
} from "@/core/messages/human-input";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types";
import { useLocalSettings } from "@/core/settings";
@ -209,14 +207,6 @@ export function SidecarPanel({ className }: { className?: string }) {
const hasPendingReferences = sidecar.activeReferences.length > 0;
const hasSidecarThread = Boolean(sidecar.sidecarThreadId);
const hasOpenHumanInputCard = useMemo(
() =>
hasOpenHumanInputRequest(
thread.messages,
(message) => !isHiddenFromUIMessage(message),
),
[thread.messages],
);
const tokenUsageInlineMode = tokenUsageEnabled
? localSettings.tokenUsage.inlineMode
: "off";
@ -226,7 +216,6 @@ export function SidecarPanel({ className }: { className?: string }) {
creatingThread ||
Boolean(queuedSubmit) ||
isUploading ||
hasOpenHumanInputCard ||
(hasSidecarThread && isHistoryLoading) ||
(sidecar.isMock ?? false) ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";

View File

@ -502,23 +502,25 @@ test("a plain reply closes only the latest unanswered request", () => {
expect(state.latestOpenRequestId).toBe("clarification:call-older");
});
test("a visible plain human reply closes an open request (legacy fallback)", () => {
// An old (v1-only) frontend renders a v2 request as plain text and the user
// answers through the normal composer — the reply has no response metadata.
// After upgrading, the request must not stay open and lock the composer.
const state = deriveHumanInputThreadState([
test("a visible plain human reply bypasses and closes an open request", () => {
// The normal composer deliberately sends no structured response metadata.
// Treating its message as the answer lets users bypass the form and also
// preserves compatibility with old frontends that render v2 as plain text.
const messages = [
toolMessage(formPayload),
{
type: "human",
content: "金额 300类别差旅",
} as unknown as Message,
]);
];
const state = deriveHumanInputThreadState(messages);
expect(state.latestOpenRequestId).toBeNull();
const answered = state.answeredResponses.get("clarification:call-form");
expect(answered?.response_kind).toBe("text");
expect(answered?.value).toBe("金额 300类别差旅");
expect(hasOpenHumanInputRequest([toolMessage(formPayload)])).toBe(true);
expect(hasOpenHumanInputRequest(messages)).toBe(false);
});
test("a visible human message before the request does not close it", () => {