deer-flow/frontend/src/components/workspace/input-box-helpers.ts
Ryker_Feng 01dc067997
feat: add composer input polishing (#3986)
* feat: add composer input polishing

* Revert "Merge branch 'main' into feat/input-polish"

This reverts commit 5b6ceccf0db3092bc62fde3b05e7816829601756, reversing
changes made to 45fbc57fef5fa5fd878cf0176c37f3e3bc7ebef6.

* Merge main into feat/input-polish

* style(frontend): format input helper polish guard

* fix(input-polish): address composer polish review findings

Frontend
- Add a cancel affordance to the in-flight polish status pill that calls
  abortInputPolishRequest(), so a slow/hung provider no longer hard-locks the
  composer for up to stream_chunk_timeout with a page reload (and draft loss)
  as the only escape.
- Reset promptHistoryIndexRef/promptHistoryDraftRef when a rewrite is applied
  (and on undo), so a stale history-browse index can no longer let the next
  ArrowDown silently overwrite the polished draft.
- Disable polishing while an open human-input card is present, matching the
  frontend/AGENTS.md rule that composer entry points defer to the card so
  card-reply metadata is preserved.
- canPolishInput now reuses parseGoalCommand/parseCompactCommand instead of a
  third hardcoded reserved-command regex, and drops the phantom /help entry
  (no /help parser exists in the composer), so future builtins only need to be
  taught to the existing parsers.

Backend
- Extract the non-graph one-shot LLM path (build model + inject Langfuse
  metadata + system/user invoke + text extract) into
  deerflow.utils.oneshot_llm.run_oneshot_llm, shared by the input-polish and
  suggestions routers so tracing-metadata and invocation shape cannot drift
  between the two copies.
- strip_think_blocks gains truncate_unclosed (default True, preserving the
  suggestions/goal JSON-prep behavior); input polish passes False so a draft
  that legitimately contains a literal <think> substring is no longer
  truncated into a partial rewrite or a spurious 503.
- Validate the empty-check and max_chars boundary against the same stripped
  view of the draft that is sent to the model, so the user-facing length
  boundary and the model input can no longer disagree.

Tests / docs
- Backend: literal-<think> preservation, whitespace-only rejection, and
  normalized-length/model-input agreement cases; suggestions tests repoint the
  create_chat_model patch to the shared helper module.
- Frontend: helper unit tests updated for the /help/reserved-command change; a
  new Playwright case covers cancelling an in-flight polish request.
- backend/AGENTS.md documents the shared one-shot helper and the polish
  normalization/think-tag behavior.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-08 17:10:27 +08:00

241 lines
5.8 KiB
TypeScript

import type { Skill } from "@/core/skills";
export {
SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN,
findSuggestionTemplatePlaceholder,
} from "@/core/suggestions/placeholders";
export const MAX_SKILL_SUGGESTIONS = 6;
export type SlashSuggestion = {
name: string;
description: string;
kind: "builtin" | "skill";
};
export type GoalCommand =
| { kind: "status" }
| { kind: "clear" }
| { kind: "set"; objective: string };
export type InputSubmitAction =
| { kind: "goal"; command: GoalCommand }
| { kind: "compact" }
| { kind: "stop" }
| { kind: "empty" }
| { kind: "message" };
export type GoalRequestState = {
controller: AbortController | null;
sequence: number;
threadId: string | null;
};
export type ActiveGoalRequest = {
controller: AbortController;
sequence: number;
threadId: string;
};
export function createGoalRequestState(): GoalRequestState {
return {
controller: null,
sequence: 0,
threadId: null,
};
}
export function beginGoalRequest(
state: GoalRequestState,
threadId: string,
): ActiveGoalRequest {
state.controller?.abort();
const controller = new AbortController();
const request = {
controller,
sequence: state.sequence + 1,
threadId,
};
state.controller = controller;
state.sequence = request.sequence;
state.threadId = threadId;
return request;
}
export function abortGoalRequest(state: GoalRequestState): void {
state.controller?.abort();
state.controller = null;
state.sequence += 1;
state.threadId = null;
}
export function finishGoalRequest(
state: GoalRequestState,
request: ActiveGoalRequest,
): void {
if (
state.controller === request.controller &&
state.sequence === request.sequence
) {
state.controller = null;
}
}
export function isCurrentGoalRequest(
state: GoalRequestState,
request: ActiveGoalRequest,
threadId: string,
): boolean {
return (
state.controller === request.controller &&
state.sequence === request.sequence &&
state.threadId === threadId &&
!request.controller.signal.aborted
);
}
export function isAbortError(error: unknown): boolean {
return (
(error instanceof DOMException && error.name === "AbortError") ||
(typeof error === "object" &&
error !== null &&
Reflect.get(error, "name") === "AbortError")
);
}
export function getLeadingSlashSkillQuery(value: string): string | null {
if (!value.startsWith("/")) {
return null;
}
const query = value.slice(1);
if (query.includes("/") || /\s/.test(query)) {
return null;
}
return query;
}
export function getMatchingSkillSuggestions(
skills: Skill[],
query: string,
builtinCommands: SlashSuggestion[],
): SlashSuggestion[] {
const normalizedQuery = query.toLowerCase();
const builtinCommandNames = new Set(
builtinCommands.map(({ name }) => name.toLowerCase()),
);
const builtinMatches = builtinCommands.filter(({ name, description }) => {
if (!normalizedQuery) {
return true;
}
return (
name.toLowerCase().includes(normalizedQuery) ||
description.toLowerCase().includes(normalizedQuery)
);
});
const skillMatches = skills
.map((skill, index) => ({
skill,
index,
name: skill.name.toLowerCase(),
}))
.filter(({ skill, name }) => {
if (!skill.enabled) {
return false;
}
if (builtinCommandNames.has(name)) {
return false;
}
return !normalizedQuery || name.includes(normalizedQuery);
})
.sort((a, b) => {
const aStartsWith = a.name.startsWith(normalizedQuery);
const bStartsWith = b.name.startsWith(normalizedQuery);
if (aStartsWith !== bStartsWith) {
return aStartsWith ? -1 : 1;
}
return a.index - b.index;
})
.slice(0, MAX_SKILL_SUGGESTIONS)
.map(({ skill }) => ({
name: skill.name,
description: skill.description,
kind: "skill" as const,
}));
return [...skillMatches, ...builtinMatches].slice(0, MAX_SKILL_SUGGESTIONS);
}
export function parseGoalCommand(value: string): GoalCommand | null {
const trimmed = value.trim();
const match = /^\/goal(?:\s+|$)/i.exec(trimmed);
if (!match) {
return null;
}
const args = trimmed.slice(match[0].length).trim();
if (!args) {
return { kind: "status" };
}
if (["clear", "reset", "off"].includes(args.toLowerCase())) {
return { kind: "clear" };
}
return { kind: "set", objective: args };
}
export function parseCompactCommand(value: string): boolean {
return /^\/(?:compact|context\s+compact)\s*$/i.test(value.trim());
}
export function canPolishInput(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) {
return false;
}
// Reserved builtin command lines are routed to their own handlers, not the
// LLM, so they must not be rewritten. Reuse the same parsers the composer
// uses to dispatch them instead of maintaining a third parallel list.
return parseGoalCommand(trimmed) === null && !parseCompactCommand(trimmed);
}
export function getInputSubmitAction({
text,
fileCount,
status,
}: {
text: string;
fileCount: number;
status: string;
}): InputSubmitAction {
const goalCommand = parseGoalCommand(text);
if (goalCommand && fileCount === 0) {
return { kind: "goal", command: goalCommand };
}
if (parseCompactCommand(text) && fileCount === 0) {
return { kind: "compact" };
}
if (status === "streaming") {
return { kind: "stop" };
}
if (!text.trim() && fileCount === 0) {
return { kind: "empty" };
}
return { kind: "message" };
}
export async function readGoalResponseError(
response: Response,
): Promise<string> {
try {
const body = (await response.json()) as { detail?: unknown };
if (typeof body.detail === "string") {
return body.detail;
}
} catch {
// Fall through to generic message.
}
return `HTTP ${response.status}`;
}