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();
+ });
+});