mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
* 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>
241 lines
5.8 KiB
TypeScript
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}`;
|
|
}
|