diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts index f89610eb6..d771654aa 100644 --- a/frontend/src/components/workspace/input-box-helpers.ts +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -1,10 +1,11 @@ import type { Skill } from "@/core/skills"; +export { + SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN, + findSuggestionTemplatePlaceholder, +} from "@/core/suggestions/placeholders"; export const MAX_SKILL_SUGGESTIONS = 6; -export const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN = - /\[(?:主题|来源|topic|source)\]/i; - export type SlashSuggestion = { name: string; description: string; @@ -100,18 +101,6 @@ export function isAbortError(error: unknown): boolean { ); } -export function findSuggestionTemplatePlaceholder(text: string) { - const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text); - if (!match) { - return null; - } - - return { - start: match.index, - end: match.index + match[0].length, - }; -} - export function getLeadingSlashSkillQuery(value: string): string | null { if (!value.startsWith("/")) { return null; diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index fcd4cb016..60403925a 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -23,7 +23,6 @@ import { useState, type ComponentProps, type KeyboardEvent, - type RefObject, } from "react"; import { toast } from "sonner"; @@ -614,7 +613,7 @@ export function InputBox({ } const placeholder = findSuggestionTemplatePlaceholder(message.text); if (placeholder) { - toast.error(t.inputBox.suggestionPlaceholderRequired); + toast.warning(t.inputBox.suggestionPlaceholderRequired); requestAnimationFrame(() => { const textarea = textareaRef.current; if (!textarea) { @@ -1069,6 +1068,18 @@ export function InputBox({ threadId, ]); + const onSelectPlaceholder = useCallback((newText: string) => { + const placeholder = findSuggestionTemplatePlaceholder(newText); + if (placeholder) { + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.focus(); + textarea.setSelectionRange(placeholder.start, placeholder.end); + }); + } + }, []); + return (
- +
)} @@ -1598,9 +1609,9 @@ export function InputBox({ } function SuggestionList({ - textareaRef, + onSelectPlaceholder, }: { - textareaRef: RefObject; + onSelectPlaceholder: (newText: string) => void; }) { const { t } = useI18n(); const { textInput } = usePromptInputController(); @@ -1608,16 +1619,9 @@ function SuggestionList({ (prompt: string | undefined) => { if (!prompt) return; textInput.setInput(prompt); - requestAnimationFrame(() => { - const textarea = textareaRef.current; - const placeholder = findSuggestionTemplatePlaceholder(prompt); - if (textarea && placeholder) { - textarea.focus(); - textarea.setSelectionRange(placeholder.start, placeholder.end); - } - }); + onSelectPlaceholder(prompt); }, - [textareaRef, textInput], + [textInput, onSelectPlaceholder], ); return ( diff --git a/frontend/src/core/suggestions/placeholders.ts b/frontend/src/core/suggestions/placeholders.ts new file mode 100644 index 000000000..70d5a6c85 --- /dev/null +++ b/frontend/src/core/suggestions/placeholders.ts @@ -0,0 +1,30 @@ +/** + * Regex matching known suggestion template placeholders. + * + * These are the exact placeholder tokens used in suggestion prompt templates + * defined in the i18n locale files (e.g., zh-CN.ts, en-US.ts). + * + * Update this pattern whenever new placeholder tokens are added to templates. + */ +export const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN = + /\[(?:主题|来源|topic|source)\]/i; + +/** + * Locates an unreplaced suggestion template placeholder in the given text. + * + * Returns the start/end character indices of the placeholder if found, + * or `null` if the text contains no known placeholder tokens. + */ +export function findSuggestionTemplatePlaceholder( + text: string, +): { start: number; end: number } | null { + const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text); + if (!match) { + return null; + } + + return { + start: match.index, + end: match.index + match[0].length, + }; +} diff --git a/frontend/tests/unit/core/suggestions/placeholders.test.ts b/frontend/tests/unit/core/suggestions/placeholders.test.ts new file mode 100644 index 000000000..3d6192492 --- /dev/null +++ b/frontend/tests/unit/core/suggestions/placeholders.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "@rstest/core"; + +import { findSuggestionTemplatePlaceholder } from "@/core/suggestions/placeholders"; + +describe("findSuggestionTemplatePlaceholder", () => { + test("finds Chinese [主题] and returns correct range", () => { + const result = findSuggestionTemplatePlaceholder( + "深入浅出的研究一下[主题],并总结发现。", + ); + expect(result).toEqual({ start: 9, end: 13 }); + }); + + test("finds English [topic] and returns correct range", () => { + const result = findSuggestionTemplatePlaceholder( + "Write a blog post about the latest trends on [topic]", + ); + expect(result).toEqual({ start: 45, end: 52 }); + }); + + test("finds Chinese [来源] placeholder", () => { + const result = + findSuggestionTemplatePlaceholder("从[来源]收集数据并创建报告。"); + expect(result).not.toBeNull(); + }); + + test("finds English [source] placeholder", () => { + const result = findSuggestionTemplatePlaceholder( + "Collect data from [source] and create a report.", + ); + expect(result).not.toBeNull(); + }); + + test("returns null for normal text without brackets", () => { + expect( + findSuggestionTemplatePlaceholder("研究一下2025年最流行的Python框架"), + ).toBeNull(); + }); + + test("returns null for text with unrelated brackets", () => { + expect( + findSuggestionTemplatePlaceholder("check [this link] for details"), + ).toBeNull(); + }); + + test("returns null for empty text", () => { + expect(findSuggestionTemplatePlaceholder("")).toBeNull(); + }); + + test("detects placeholder case-insensitively for English", () => { + expect( + findSuggestionTemplatePlaceholder("Research [Topic] deeply"), + ).not.toBeNull(); + expect( + findSuggestionTemplatePlaceholder("Research [TOPIC] deeply"), + ).not.toBeNull(); + }); +});