mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-25 07:28:07 +00:00
feat(workspace): validate /goal objective length in composer (#4337)
The composer sent `/goal <objective>` of any length, so an objective past the backend limit (`MAX_GOAL_OBJECTIVE_CHARS = 4000`) only failed after a round-trip with a raw HTTP 422. Mirror the limit client-side: reject an over-length objective before the PUT with a friendly toast, and reject the submit so PromptInput keeps the user's text for editing instead of clearing it. Add a live footer counter that surfaces near the limit and turns destructive when exceeded. Length is measured with the same whitespace normalization the backend applies, so the counts match exactly.
This commit is contained in:
parent
f113f10f36
commit
e283341c55
@ -6,6 +6,52 @@ export {
|
||||
|
||||
export const MAX_SKILL_SUGGESTIONS = 6;
|
||||
|
||||
// Mirror of the backend raw request limit (`ThreadGoalRequest.objective`
|
||||
// max_length and `MAX_GOAL_OBJECTIVE_CHARS` in backend goal.py). Kept here so
|
||||
// the composer can reject an over-length `/goal <objective>` before issuing the
|
||||
// PUT request and show a friendly error instead of surfacing a raw HTTP 422.
|
||||
export const MAX_GOAL_OBJECTIVE_CHARS = 4000;
|
||||
|
||||
export function isGoalObjectiveTooLong(objective: string): boolean {
|
||||
return objective.length > MAX_GOAL_OBJECTIVE_CHARS;
|
||||
}
|
||||
|
||||
// The live composer counter stays hidden until the objective approaches the
|
||||
// limit, so it only surfaces when the user is at risk of being rejected rather
|
||||
// than adding permanent noise to the footer.
|
||||
export const GOAL_OBJECTIVE_COUNTER_VISIBLE_AT = Math.floor(
|
||||
MAX_GOAL_OBJECTIVE_CHARS * 0.9,
|
||||
);
|
||||
|
||||
export type GoalObjectiveCounter = {
|
||||
length: number;
|
||||
max: number;
|
||||
overLimit: boolean;
|
||||
};
|
||||
|
||||
// Derive the live counter for the composer footer from the same parsed
|
||||
// objective string sent to the API. Returns null unless the input is a
|
||||
// `/goal <objective>` set command whose raw length has reached the visibility
|
||||
// threshold, so the counter only appears for the case the limit actually
|
||||
// applies to.
|
||||
export function getGoalObjectiveCounter(
|
||||
value: string,
|
||||
): GoalObjectiveCounter | null {
|
||||
const command = parseGoalCommand(value);
|
||||
if (command?.kind !== "set") {
|
||||
return null;
|
||||
}
|
||||
const length = command.objective.length;
|
||||
if (length < GOAL_OBJECTIVE_COUNTER_VISIBLE_AT) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
length,
|
||||
max: MAX_GOAL_OBJECTIVE_CHARS,
|
||||
overLimit: length > MAX_GOAL_OBJECTIVE_CHARS,
|
||||
};
|
||||
}
|
||||
|
||||
export type SlashSuggestion = {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@ -140,12 +140,15 @@ import {
|
||||
createGoalRequestState,
|
||||
findSuggestionTemplatePlaceholder,
|
||||
finishGoalRequest,
|
||||
getGoalObjectiveCounter,
|
||||
getInputSubmitAction,
|
||||
getLeadingSlashSkillQuery,
|
||||
getMatchingSkillSuggestions,
|
||||
type GoalCommand,
|
||||
isAbortError,
|
||||
isCurrentGoalRequest,
|
||||
isGoalObjectiveTooLong,
|
||||
MAX_GOAL_OBJECTIVE_CHARS,
|
||||
readGoalResponseError,
|
||||
type SlashSuggestion,
|
||||
} from "./input-box-helpers";
|
||||
@ -1145,6 +1148,19 @@ export function InputBox({
|
||||
status,
|
||||
});
|
||||
if (submitAction.kind === "goal") {
|
||||
if (
|
||||
submitAction.command.kind === "set" &&
|
||||
isGoalObjectiveTooLong(submitAction.command.objective)
|
||||
) {
|
||||
toast.error(
|
||||
t.inputBox.goalTooLong.replace("{max}", () =>
|
||||
String(MAX_GOAL_OBJECTIVE_CHARS),
|
||||
),
|
||||
);
|
||||
// Reject so the composer keeps the user's text for editing instead of
|
||||
// clearing it (PromptInput only preserves input on a rejected submit).
|
||||
return Promise.reject(new Error("goal-too-long"));
|
||||
}
|
||||
promptHistoryIndexRef.current = null;
|
||||
promptHistoryDraftRef.current = "";
|
||||
setFollowups([]);
|
||||
@ -1184,6 +1200,7 @@ export function InputBox({
|
||||
selectedSlashSkill,
|
||||
status,
|
||||
submitThreadMessage,
|
||||
t.inputBox.goalTooLong,
|
||||
t.inputBox.pleaseWaitStreaming,
|
||||
],
|
||||
);
|
||||
@ -1243,6 +1260,10 @@ export function InputBox({
|
||||
() => getLeadingSlashSkillQuery(textInput.value ?? ""),
|
||||
[textInput.value],
|
||||
);
|
||||
const goalObjectiveCounter = useMemo(
|
||||
() => getGoalObjectiveCounter(textInput.value ?? ""),
|
||||
[textInput.value],
|
||||
);
|
||||
const skillSuggestions = useMemo(
|
||||
() =>
|
||||
slashSkillQuery === null
|
||||
@ -2566,6 +2587,24 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className="min-w-0 justify-end">
|
||||
{goalObjectiveCounter && (
|
||||
<span
|
||||
aria-label={t.inputBox.goalLengthCounter
|
||||
.replace("{length}", () =>
|
||||
String(goalObjectiveCounter.length),
|
||||
)
|
||||
.replace("{max}", () => String(goalObjectiveCounter.max))}
|
||||
className={cn(
|
||||
"shrink-0 text-xs tabular-nums",
|
||||
goalObjectiveCounter.overLimit
|
||||
? "text-destructive font-medium"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
data-testid="goal-length-counter"
|
||||
>
|
||||
{goalObjectiveCounter.length}/{goalObjectiveCounter.max}
|
||||
</span>
|
||||
)}
|
||||
<ModelSelector
|
||||
open={modelDialogOpen}
|
||||
onOpenChange={setModelDialogOpen}
|
||||
|
||||
@ -189,6 +189,8 @@ export const enUS: Translations = {
|
||||
goalNone: "No active goal.",
|
||||
goalActive: "Active goal: {goal}",
|
||||
goalFailed: "Goal command failed.",
|
||||
goalTooLong: "Goal is too long. Keep it under {max} characters.",
|
||||
goalLengthCounter: "Goal length: {length}/{max} characters",
|
||||
compactSuccess:
|
||||
"Earlier context compacted. The full chat remains visible; future model calls will use the summary and recent messages.",
|
||||
compactSkipped: "The current context does not need compaction yet.",
|
||||
|
||||
@ -156,6 +156,8 @@ export interface Translations {
|
||||
goalNone: string;
|
||||
goalActive: string;
|
||||
goalFailed: string;
|
||||
goalTooLong: string;
|
||||
goalLengthCounter: string;
|
||||
compactSuccess: string;
|
||||
compactSkipped: string;
|
||||
compactFailed: string;
|
||||
|
||||
@ -177,6 +177,8 @@ export const zhCN: Translations = {
|
||||
goalNone: "当前没有目标。",
|
||||
goalActive: "当前目标:{goal}",
|
||||
goalFailed: "目标命令执行失败。",
|
||||
goalTooLong: "目标过长,请控制在 {max} 个字符以内。",
|
||||
goalLengthCounter: "目标长度:{length}/{max} 字符",
|
||||
compactSuccess:
|
||||
"已压缩早期上下文。完整聊天记录仍保留,后续模型将基于摘要和最近消息继续。",
|
||||
compactSkipped: "当前上下文还不需要压缩。",
|
||||
|
||||
@ -7,11 +7,15 @@ import {
|
||||
createGoalRequestState,
|
||||
findSuggestionTemplatePlaceholder,
|
||||
finishGoalRequest,
|
||||
getGoalObjectiveCounter,
|
||||
getInputSubmitAction,
|
||||
getLeadingSlashSkillQuery,
|
||||
getMatchingSkillSuggestions,
|
||||
GOAL_OBJECTIVE_COUNTER_VISIBLE_AT,
|
||||
isAbortError,
|
||||
isCurrentGoalRequest,
|
||||
isGoalObjectiveTooLong,
|
||||
MAX_GOAL_OBJECTIVE_CHARS,
|
||||
parseCompactCommand,
|
||||
parseGoalCommand,
|
||||
readGoalResponseError,
|
||||
@ -64,6 +68,98 @@ describe("parseGoalCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGoalObjectiveTooLong", () => {
|
||||
it("allows objectives up to the limit", () => {
|
||||
expect(isGoalObjectiveTooLong("a")).toBe(false);
|
||||
expect(isGoalObjectiveTooLong("a".repeat(MAX_GOAL_OBJECTIVE_CHARS))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("flags objectives past the limit", () => {
|
||||
expect(
|
||||
isGoalObjectiveTooLong("a".repeat(MAX_GOAL_OBJECTIVE_CHARS + 1)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("mirrors the backend limit of 4000 characters", () => {
|
||||
expect(MAX_GOAL_OBJECTIVE_CHARS).toBe(4000);
|
||||
});
|
||||
|
||||
it("counts interior whitespace because the backend validates raw request length", () => {
|
||||
const interiorPastLimit = `${"a".repeat(2000)} ${"a".repeat(1999)}`;
|
||||
expect(interiorPastLimit.length).toBe(MAX_GOAL_OBJECTIVE_CHARS + 3);
|
||||
expect(isGoalObjectiveTooLong(interiorPastLimit)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the parsed objective after command boundary whitespace is trimmed", () => {
|
||||
const command = parseGoalCommand(
|
||||
`/goal ${"a".repeat(MAX_GOAL_OBJECTIVE_CHARS)} `,
|
||||
);
|
||||
expect(command).toEqual({
|
||||
kind: "set",
|
||||
objective: "a".repeat(MAX_GOAL_OBJECTIVE_CHARS),
|
||||
});
|
||||
if (command?.kind !== "set") {
|
||||
throw new Error("expected a /goal set command");
|
||||
}
|
||||
expect(isGoalObjectiveTooLong(command.objective)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGoalObjectiveCounter", () => {
|
||||
it("returns null for non-goal or non-set inputs", () => {
|
||||
expect(getGoalObjectiveCounter("hello")).toBeNull();
|
||||
expect(getGoalObjectiveCounter("/goal")).toBeNull();
|
||||
expect(getGoalObjectiveCounter("/goal clear")).toBeNull();
|
||||
});
|
||||
|
||||
it("stays hidden while the objective is comfortably under the limit", () => {
|
||||
expect(getGoalObjectiveCounter("/goal ship it")).toBeNull();
|
||||
const justBelowThreshold = "a".repeat(
|
||||
GOAL_OBJECTIVE_COUNTER_VISIBLE_AT - 1,
|
||||
);
|
||||
expect(getGoalObjectiveCounter(`/goal ${justBelowThreshold}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("appears once the objective reaches the visibility threshold", () => {
|
||||
const atThreshold = "a".repeat(GOAL_OBJECTIVE_COUNTER_VISIBLE_AT);
|
||||
expect(getGoalObjectiveCounter(`/goal ${atThreshold}`)).toEqual({
|
||||
length: GOAL_OBJECTIVE_COUNTER_VISIBLE_AT,
|
||||
max: MAX_GOAL_OBJECTIVE_CHARS,
|
||||
overLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the counter over the limit and measures raw parsed length", () => {
|
||||
const overLimit = "a".repeat(MAX_GOAL_OBJECTIVE_CHARS + 5);
|
||||
expect(getGoalObjectiveCounter(`/goal ${overLimit}`)).toEqual({
|
||||
length: MAX_GOAL_OBJECTIVE_CHARS + 5,
|
||||
max: MAX_GOAL_OBJECTIVE_CHARS,
|
||||
overLimit: true,
|
||||
});
|
||||
|
||||
// Interior whitespace is preserved in the request body, so it must count
|
||||
// toward the same raw max_length enforced by the backend binding.
|
||||
const padded = `${"a".repeat(2000)} ${"a".repeat(1999)}`;
|
||||
expect(getGoalObjectiveCounter(`/goal ${padded}`)).toEqual({
|
||||
length: MAX_GOAL_OBJECTIVE_CHARS + 3,
|
||||
max: MAX_GOAL_OBJECTIVE_CHARS,
|
||||
overLimit: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
getGoalObjectiveCounter(
|
||||
`/goal ${"a".repeat(MAX_GOAL_OBJECTIVE_CHARS)} `,
|
||||
),
|
||||
).toEqual({
|
||||
length: MAX_GOAL_OBJECTIVE_CHARS,
|
||||
max: MAX_GOAL_OBJECTIVE_CHARS,
|
||||
overLimit: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCompactCommand", () => {
|
||||
it("matches compact commands", () => {
|
||||
expect(parseCompactCommand("/compact")).toBe(true);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user