This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 29ca9af7d feat(ai): add a prompt template library (#4215)
29ca9af7d is described below

commit 29ca9af7d5dc11dde8c2bfb29f10f217b805a0f9
Author: coder999o <[email protected]>
AuthorDate: Wed Sep 16 16:14:06 2026 +0800

    feat(ai): add a prompt template library (#4215)
---
 web/src/i18n/translations.ts               | 104 ++++++++
 web/src/pages/ai/__tests__/AiPage.test.tsx |  58 +++++
 web/src/pages/ai/index.tsx                 | 275 +++++++++++++++++++-
 web/src/pages/ai/promptTemplates.test.ts   | 222 ++++++++++++++++
 web/src/pages/ai/promptTemplates.ts        | 401 +++++++++++++++++++++++++++++
 5 files changed, 1054 insertions(+), 6 deletions(-)

diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 612703077..bb6f616de 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1348,6 +1348,110 @@ const translations: Record<string, Record<Lang, 
string>> = {
     zh: '集群列表加载失败,已显示全局工具',
     en: 'Failed to load clusters; showing global tools',
   },
+  'ai.mode.title': { zh: '对话模式', en: 'Chat mode' },
+  'ai.mode.chat': { zh: '对话', en: 'Chat' },
+  'ai.mode.diagnose': { zh: '诊断', en: 'Diagnose' },
+  'ai.mode.manage': { zh: '运维', en: 'Manage' },
+  'ai.mode.query': { zh: '查询', en: 'Query' },
+  'ai.promptEnhance': { zh: 'Prompt 增强', en: 'Prompt enhancement' },
+  'ai.promptEnhanceTitle': { zh: '发送前增强 Prompt', en: 'Enhance the prompt 
before sending' },
+  'ai.promptTemplates.button': { zh: '模板', en: 'Templates' },
+  'ai.promptTemplates.title': { zh: 'Prompt 模板', en: 'Prompt templates' },
+  'ai.promptTemplates.storageUnavailable': {
+    zh: '浏览器存储不可用,自定义 Prompt 模板暂时无法保存。',
+    en: 'Browser storage is unavailable. Custom prompt templates cannot be 
saved right now.',
+  },
+  'ai.promptTemplates.searchAria': { zh: '搜索 Prompt 模板', en: 'Search prompt 
templates' },
+  'ai.promptTemplates.searchPlaceholder': {
+    zh: '搜索模板、标签或内容',
+    en: 'Search templates, tags, or content',
+  },
+  'ai.promptTemplates.saveCurrent': { zh: '保存当前输入', en: 'Save current input' },
+  'ai.promptTemplates.titleInput': { zh: '模板标题', en: 'Template title' },
+  'ai.promptTemplates.tagsInput': { zh: '模板标签', en: 'Template tags' },
+  'ai.promptTemplates.tagsPlaceholder': { zh: '标签,逗号分隔', en: 'Tags, separated 
by commas' },
+  'ai.promptTemplates.empty': { zh: '暂无 Prompt 模板', en: 'No prompt templates' 
},
+  'ai.promptTemplates.use': { zh: '使用', en: 'Use' },
+  'ai.promptTemplates.append': { zh: '追加', en: 'Append' },
+  'ai.promptTemplates.deleteConfirm': {
+    zh: '删除这个 Prompt 模板?',
+    en: 'Delete this prompt template?',
+  },
+  'ai.promptTemplates.deleteAria': { zh: '删除模板 {title}', en: 'Delete template 
{title}' },
+  'ai.promptTemplates.custom': { zh: '自定义', en: 'Custom' },
+  'ai.promptTemplates.builtin': { zh: '内置', en: 'Built-in' },
+  'ai.promptTemplates.titleRequired': { zh: '请输入模板标题', en: 'Enter a template 
title' },
+  'ai.promptTemplates.bodyRequired': {
+    zh: '当前输入为空,无法保存模板',
+    en: 'The current input is empty and cannot be saved as a template',
+  },
+  'ai.promptTemplates.storageSaveFailed': {
+    zh: '模板保存失败,浏览器存储不可用',
+    en: 'Failed to save the template because browser storage is unavailable',
+  },
+  'ai.promptTemplates.storageDeleteFailed': {
+    zh: '模板删除失败,浏览器存储不可用',
+    en: 'Failed to delete the template because browser storage is unavailable',
+  },
+  'ai.promptTemplates.saved': { zh: 'Prompt 模板已保存', en: 'Prompt template 
saved' },
+  'ai.promptTemplates.deleted': { zh: 'Prompt 模板已删除', en: 'Prompt template 
deleted' },
+  'ai.promptTemplates.consumerLag.title': { zh: '消费延迟诊断', en: 'Consumer Lag 
Diagnosis' },
+  'ai.promptTemplates.consumerLag.description': {
+    zh: '排查消费堆积、在线客户端、订阅和 Broker 进度返回状态。',
+    en: 'Inspect backlog, online clients, subscriptions, and Broker progress 
availability.',
+  },
+  'ai.promptTemplates.consumerLag.body': {
+    zh: '请诊断当前 RocketMQ 实例中的消费延迟问题。\n\n检查范围:\n1. 找出堆积最高的消费组和 Topic。\n2. 
对比消费组在线客户端、订阅关系、重试队列和队列级 offset。\n3. 判断是生产突增、消费者离线、订阅漂移、Broker 
进度不可查,还是单队列热点。\n4. 给出可执行的恢复步骤,并标注哪些步骤只读、哪些步骤会修改集群状态。',
+    en: 'Diagnose consumer lag in the current RocketMQ instance.\n\nScope:\n1. 
Find the consumer groups and topics with the highest backlog.\n2. Compare 
online clients, subscriptions, retry queues, and queue-level offsets.\n3. 
Decide whether the cause is a producer spike, offline consumers, subscription 
drift, unavailable Broker progress, or a single hot queue.\n4. Provide 
executable recovery steps and mark which steps are read-only and which modify 
cluster state.',
+  },
+  'ai.promptTemplates.messageTrace.title': {
+    zh: '消息轨迹排查',
+    en: 'Message Trace Investigation',
+  },
+  'ai.promptTemplates.messageTrace.description': {
+    zh: '按 Message ID 或 Key 收集发送、存储、消费和重试线索。',
+    en: 'Collect send, storage, consume, and retry evidence by Message ID or 
key.',
+  },
+  'ai.promptTemplates.messageTrace.body': {
+    zh: '请协助排查一条消息的完整轨迹。\n\n已知信息:\n- Message ID:\n- Topic:\n- Key 或业务单号:\n- 
大致发送时间:\n\n请先说明需要哪些查询条件,然后按发送结果、Broker 存储、消费结果、重试/DLQ 四部分输出结论。',
+    en: 'Help investigate the full trace for one message.\n\nKnown 
information:\n- Message ID:\n- Topic:\n- Key or business identifier:\n- 
Approximate send time:\n\nFirst state which query conditions are required, then 
summarize the send result, Broker storage, consume result, and retry/DLQ 
state.',
+  },
+  'ai.promptTemplates.topicChange.title': { zh: 'Topic 变更预案', en: 'Topic 
Change Plan' },
+  'ai.promptTemplates.topicChange.description': {
+    zh: '生成 Topic 队列、权限、TTL 或保留策略变更前的检查清单。',
+    en: 'Build a precheck list before changing topic queues, permissions, TTL, 
or retention.',
+  },
+  'ai.promptTemplates.topicChange.body': {
+    zh: '请为一次 RocketMQ Topic 变更生成执行预案。\n\n变更目标:\n- Topic:\n- 期望调整:\n- 
影响窗口:\n\n请输出:变更前检查、风险判断、执行步骤、回滚步骤、验证方式。涉及写操作时先列出需要人工确认的命令或接口。',
+    en: 'Create an execution plan for a RocketMQ topic change.\n\nChange 
target:\n- Topic:\n- Expected adjustment:\n- Impact window:\n\nOutput the 
pre-change checks, risk assessment, execution steps, rollback steps, and 
verification method. For write operations, list the commands or APIs that 
require manual confirmation first.',
+  },
+  'ai.promptTemplates.brokerHealth.title': { zh: 'Broker 健康巡检', en: 'Broker 
Health Check' },
+  'ai.promptTemplates.brokerHealth.description': {
+    zh: '汇总 Broker 可用性、磁盘水位、TPS、队列分布和异常告警。',
+    en: 'Summarize Broker availability, disk usage, TPS, queue distribution, 
and abnormal alerts.',
+  },
+  'ai.promptTemplates.brokerHealth.body': {
+    zh: '请对当前 RocketMQ 集群做一次 Broker 健康巡检。\n\n请关注:\n1. Broker 可用性和主从状态。\n2. 
磁盘水位、TPS、消息量和异常告警。\n3. Topic 队列分布是否存在热点或倾斜。\n4. 
最近是否有失败的告警通知或未确认系统告警。\n\n输出时请区分“已验证事实”“需要进一步查询”“建议处理动作”。',
+    en: 'Run a Broker health check for the current RocketMQ cluster.\n\nFocus 
on:\n1. Broker availability and master-slave status.\n2. Disk usage, TPS, 
message volume, and abnormal alerts.\n3. Whether topic queue distribution has 
hot spots or skew.\n4. Whether there are recent failed alert notifications or 
unacknowledged system alerts.\n\nSeparate the output into verified facts, items 
requiring more queries, and recommended actions.',
+  },
+  'ai.promptTemplates.opsSummary.title': { zh: '运维变更摘要', en: 'Operations 
Handoff Summary' },
+  'ai.promptTemplates.opsSummary.description': {
+    zh: '把已执行的查询结果整理成面向值班和复盘的摘要。',
+    en: 'Turn completed query results into a handoff and review summary.',
+  },
+  'ai.promptTemplates.opsSummary.body': {
+    zh: '请把本次 RocketMQ 运维排查过程整理成简洁摘要。\n\n摘要结构:\n- 背景:\n- 已执行查询:\n- 关键发现:\n- 
已完成操作:\n- 未完成风险:\n- 下一步:',
+    en: 'Summarize this RocketMQ operations investigation 
concisely.\n\nStructure:\n- Background:\n- Queries executed:\n- Key 
findings:\n- Completed actions:\n- Remaining risks:\n- Next steps:',
+  },
+  'ai.promptTemplates.aclRisk.title': { zh: 'ACL 风险核对', en: 'ACL Risk Check' },
+  'ai.promptTemplates.aclRisk.description': {
+    zh: '检查用户、策略、实例绑定和高危权限是否符合预期。',
+    en: 'Check users, policies, instance bindings, and high-risk permissions.',
+  },
+  'ai.promptTemplates.aclRisk.body': {
+    zh: '请核对当前 RocketMQ Studio 的 ACL 风险。\n\n请检查:\n1. 
是否存在管理员权限、通配资源或写权限过宽的账号。\n2. 用户和实例绑定是否符合最小权限。\n3. 云厂商实例与 Apache 实例的 ACL 
字段差异。\n4. 哪些发现需要立即处理,哪些只是建议优化。',
+    en: 'Review ACL risks in the current RocketMQ Studio 
environment.\n\nCheck:\n1. Whether any account has admin permissions, wildcard 
resources, or overly broad write permissions.\n2. Whether user and instance 
bindings follow least privilege.\n3. ACL field differences between cloud vendor 
instances and Apache instances.\n4. Which findings require immediate handling 
and which are optimization suggestions.',
+  },
 
   // ─── Home Page ───
   'home.banner': {
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index ea72b1ff9..71b32b6c1 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -83,6 +83,7 @@ describe('AiPage tool runner', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     dataModeMocks.useMock = false;
+    localStorage.clear();
     sessionStorage.clear();
     useAiChatHistoryStore.setState({
       histories: {
@@ -425,6 +426,63 @@ describe('AiPage tool runner', () => {
     expect(chatStream).toHaveBeenCalledTimes(1);
   });
 
+  it('applies a builtin prompt template with its mode and enhancement 
setting', async () => {
+    vi.mocked(chatStream).mockResolvedValue(undefined);
+    const user = userEvent.setup();
+    renderPage();
+    const input = await screen.findByPlaceholderText(
+      '输入你的问题或指令,例如:查看集群状态、创建 Topic、诊断消费延迟...',
+    );
+    await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+
+    await user.click(screen.getByRole('button', { name: '模板' }));
+    const dialog = await screen.findByRole('dialog', { name: 'Prompt 模板' });
+    expect(within(dialog).getByText('消费延迟诊断')).toBeInTheDocument();
+    await user.click(within(dialog).getAllByRole('button', { name: '使用' })[0]);
+
+    expect((input as HTMLTextAreaElement).value).toContain(
+      '请诊断当前 RocketMQ 实例中的消费延迟问题',
+    );
+    expect(screen.getAllByTitle('对话模式')[0]).toHaveTextContent('诊断');
+    expect(screen.getByTitle('发送前增强 Prompt')).toHaveStyle({ borderColor: 
'#1677ff' });
+
+    fireEvent.keyDown(input, { key: 'Enter' });
+
+    await waitFor(() =>
+      expect(chatStream).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: expect.stringContaining('请诊断当前 RocketMQ 实例中的消费延迟问题'),
+          mode: 'diagnose',
+          enhance: true,
+        }),
+        expect.any(Function),
+        expect.any(AbortSignal),
+        expect.any(Function),
+      ),
+    );
+  });
+
+  it('saves the current input as a custom prompt template', async () => {
+    const user = userEvent.setup();
+    renderPage();
+    const input = await screen.findByPlaceholderText(
+      '输入你的问题或指令,例如:查看集群状态、创建 Topic、诊断消费延迟...',
+    );
+    await waitFor(() => expect(getLlmModels).toHaveBeenCalled());
+    fireEvent.change(input, { target: { value: '检查所有 Broker 的磁盘水位并给出风险排序' } });
+
+    await user.click(screen.getByRole('button', { name: '模板' }));
+    const dialog = await screen.findByRole('dialog', { name: 'Prompt 模板' });
+    await user.type(within(dialog).getByRole('textbox', { name: '模板标题' }), 
'磁盘水位巡检');
+    await user.type(within(dialog).getByRole('textbox', { name: '模板标签' }), 
'broker,disk');
+    await user.click(within(dialog).getByRole('button', { name: '保存' }));
+
+    expect(await screen.findByText('Prompt 模板已保存')).toBeInTheDocument();
+    expect(within(dialog).getByText('磁盘水位巡检')).toBeInTheDocument();
+    expect(within(dialog).getByText('自定义')).toBeInTheDocument();
+    
expect(localStorage.getItem('rocketmq-studio-ai-prompt-templates')).toContain('磁盘水位巡检');
+  });
+
   it('loads the catalog, creates a schema template, and renders structured 
output', async () => {
     const user = userEvent.setup();
     vi.mocked(executeTool).mockResolvedValue({
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index e5fadb317..f17a0bfa1 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -36,18 +36,24 @@ import {
   Select,
   Alert,
   Input,
+  List,
   Modal,
+  Popconfirm,
+  Segmented,
   Space,
   theme,
   message,
 } from 'antd';
 import {
   ArrowUp,
+  BookOpen,
   CaretDown,
   ClockCounterClockwise,
+  Plus,
   SlidersHorizontal,
   Sparkle,
   Stop,
+  Trash,
 } from '@phosphor-icons/react';
 import type { ColumnsType } from 'antd/es/table';
 import { useLang } from '../../i18n/LangContext';
@@ -66,6 +72,16 @@ import {
   useAiChatHistoryStore,
 } from '../../stores/aiChatHistoryStore';
 import { getChatDraft, shouldOpenChatHistory, type ChatMode } from 
'./chatDraft';
+import {
+  applyPromptTemplate,
+  buildPromptTemplatePreview,
+  deleteCustomPromptTemplate,
+  filterPromptTemplates,
+  loadPromptTemplateCatalog,
+  saveCustomPromptTemplate,
+  type PromptTemplate,
+  type PromptTemplateApplyMode,
+} from './promptTemplates';
 
 const { Text } = Typography;
 
@@ -472,6 +488,14 @@ const AiPage = () => {
   const [selectedModel, setSelectedModel] = useState('');
   const [toolModalOpen, setToolModalOpen] = useState(false);
   const [enhance, setEnhance] = useState(false);
+  const [selectedMode, setSelectedMode] = useState<ChatMode>('chat');
+  const [promptTemplateOpen, setPromptTemplateOpen] = useState(false);
+  const [promptTemplates, setPromptTemplates] = useState<PromptTemplate[]>([]);
+  const [promptTemplateSearch, setPromptTemplateSearch] = useState('');
+  const [promptTemplateMode, setPromptTemplateMode] = useState<ChatMode | 
'all'>('all');
+  const [promptTemplateStorageAvailable, setPromptTemplateStorageAvailable] = 
useState(true);
+  const [customTemplateTitle, setCustomTemplateTitle] = useState('');
+  const [customTemplateTags, setCustomTemplateTags] = useState('');
   const [tools, setTools] = useState<McpTool[]>([]);
   const [toolsLoading, setToolsLoading] = useState(false);
   const [clusterOptions, setClusterOptions] = useState<{ value: string; label: 
string }[]>([]);
@@ -498,6 +522,94 @@ const AiPage = () => {
     enhance?: boolean;
   } | null>(null);
   const canInspectLlmRuntime = !userId || admin === true;
+  const chatModeOptions = useMemo<Array<{ value: ChatMode; label: string }>>(
+    () => [
+      { value: 'chat', label: t('ai.mode.chat') },
+      { value: 'diagnose', label: t('ai.mode.diagnose') },
+      { value: 'manage', label: t('ai.mode.manage') },
+      { value: 'query', label: t('ai.mode.query') },
+    ],
+    [t],
+  );
+  const promptTemplateFilterOptions = useMemo<Array<{ value: ChatMode | 'all'; 
label: string }>>(
+    () => [{ value: 'all', label: t('common.all') }, ...chatModeOptions],
+    [chatModeOptions, t],
+  );
+  const chatModeLabels = useMemo(
+    () => Object.fromEntries(chatModeOptions.map((item) => [item.value, 
item.label])),
+    [chatModeOptions],
+  );
+  const visiblePromptTemplates = useMemo(
+    () => filterPromptTemplates(promptTemplates, promptTemplateSearch, 
promptTemplateMode),
+    [promptTemplateMode, promptTemplateSearch, promptTemplates],
+  );
+
+  const refreshPromptTemplates = useCallback(() => {
+    const catalog = loadPromptTemplateCatalog(undefined, t);
+    setPromptTemplates(catalog.templates);
+    setPromptTemplateStorageAvailable(catalog.storageAvailable);
+  }, [t]);
+
+  const openPromptTemplates = useCallback(() => {
+    refreshPromptTemplates();
+    setPromptTemplateOpen(true);
+  }, [refreshPromptTemplates]);
+
+  const handleApplyPromptTemplate = useCallback(
+    (template: PromptTemplate, applyMode: PromptTemplateApplyMode) => {
+      setInputValue((current) => applyPromptTemplate(template, current, 
applyMode));
+      setSelectedMode(template.mode);
+      setEnhance(template.enhance);
+      setPromptTemplateOpen(false);
+      window.setTimeout(() => textareaRef.current?.focus(), 0);
+    },
+    [],
+  );
+
+  const handleSaveCurrentPromptTemplate = useCallback(() => {
+    const result = saveCustomPromptTemplate({
+      title: customTemplateTitle,
+      tags: customTemplateTags,
+      mode: selectedMode,
+      enhance,
+      body: inputValue,
+    });
+    if (!result.ok) {
+      const errorMessage =
+        result.reason === 'empty_title'
+          ? t('ai.promptTemplates.titleRequired')
+          : result.reason === 'empty_body'
+            ? t('ai.promptTemplates.bodyRequired')
+            : t('ai.promptTemplates.storageSaveFailed');
+      message.error(errorMessage);
+      return;
+    }
+    message.success(t('ai.promptTemplates.saved'));
+    setCustomTemplateTitle('');
+    setCustomTemplateTags('');
+    refreshPromptTemplates();
+  }, [
+    customTemplateTags,
+    customTemplateTitle,
+    enhance,
+    inputValue,
+    refreshPromptTemplates,
+    selectedMode,
+    t,
+  ]);
+
+  const handleDeletePromptTemplate = useCallback(
+    (template: PromptTemplate) => {
+      if (template.scope !== 'custom') return;
+      if (!deleteCustomPromptTemplate(template.id)) {
+        message.error(t('ai.promptTemplates.storageDeleteFailed'));
+        return;
+      }
+      message.success(t('ai.promptTemplates.deleted'));
+      refreshPromptTemplates();
+    },
+    [refreshPromptTemplates, t],
+  );
 
   const scrollToBottom = useCallback(() => {
     chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -585,6 +697,7 @@ const AiPage = () => {
       }
       if (draft.prompt) setInputValue(draft.prompt);
       if (draft.enhance !== undefined) setEnhance(draft.enhance);
+      if (draft.mode) setSelectedMode(draft.mode);
       if (draft.engine) {
         draftEngineRef.current = draft.engine;
         setEngine(draft.engine);
@@ -633,10 +746,11 @@ const AiPage = () => {
       textOverride?: string,
       modelOverride?: string,
       enhance?: boolean,
-      modeOverride: ChatMode = 'chat',
+      modeOverride?: ChatMode,
     ) => {
       const text = (textOverride ?? inputValue).trim();
       const model = modelOverride ?? selectedModel;
+      const requestMode = modeOverride ?? selectedMode;
       if (!text || loading || chatInFlightRef.current) return;
       if (!llmReady) {
         message.warning(t('ai.providerRequired'));
@@ -677,7 +791,7 @@ const AiPage = () => {
         await chatStream(
           {
             message: text,
-            mode: modeOverride,
+            mode: requestMode,
             model,
             engine,
             enhance,
@@ -740,6 +854,7 @@ const AiPage = () => {
       llmReady,
       loading,
       selectedModel,
+      selectedMode,
       startConversation,
       t,
       updateMessages,
@@ -763,10 +878,10 @@ const AiPage = () => {
       if (e.nativeEvent.isComposing) return;
       if (e.key === 'Enter' && !e.shiftKey) {
         e.preventDefault();
-        handleSend();
+        handleSend(undefined, undefined, enhance);
       }
     },
-    [handleSend],
+    [enhance, handleSend],
   );
 
   const handleQuickAction = useCallback((action: string) => {
@@ -1052,6 +1167,17 @@ const AiPage = () => {
                 title="执行引擎"
                 style={{ fontSize: '0.893rem', minWidth: 110 }}
               />
+              <Select
+                size="small"
+                value={selectedMode}
+                onChange={(value: ChatMode) => setSelectedMode(value)}
+                options={chatModeOptions}
+                variant="borderless"
+                popupMatchSelectWidth={false}
+                suffixIcon={<CaretDown size={10} color="#9CA3AF" />}
+                title={t('ai.mode.title')}
+                style={{ fontSize: '0.893rem', minWidth: 90 }}
+              />
               {llmConfig && (
                 <Tag color={llmReady ? 'green' : 'default'} style={{ 
borderRadius: 6 }}>
                   {llmConfig.provider || 'openai'}
@@ -1101,6 +1227,10 @@ const AiPage = () => {
                       <SlidersHorizontal size={17} />
                       <span>工具</span>
                     </button>
+                    <button className="tool-btn" onClick={openPromptTemplates}>
+                      <BookOpen size={17} />
+                      <span>{t('ai.promptTemplates.button')}</span>
+                    </button>
                     <button
                       className="tool-btn"
                       onClick={() => setEnhance((value) => !value)}
@@ -1108,10 +1238,10 @@ const AiPage = () => {
                         borderColor: enhance ? '#1677ff' : undefined,
                         color: enhance ? '#1677ff' : undefined,
                       }}
-                      title="发送前增强 Prompt"
+                      title={t('ai.promptEnhanceTitle')}
                     >
                       <Sparkle size={17} />
-                      <span>Prompt 增强</span>
+                      <span>{t('ai.promptEnhance')}</span>
                     </button>
                   </div>
                 </div>
@@ -1205,6 +1335,139 @@ const AiPage = () => {
         )}
       </Drawer>
 
+      <Modal
+        title={t('ai.promptTemplates.title')}
+        open={promptTemplateOpen}
+        onCancel={() => setPromptTemplateOpen(false)}
+        footer={null}
+        width={760}
+        styles={{ body: { maxHeight: 'calc(100vh - 240px)', overflowY: 'auto' 
} }}
+      >
+        <Flex vertical gap={16} style={{ paddingTop: 8 }}>
+          {!promptTemplateStorageAvailable && (
+            <Alert type="warning" showIcon 
message={t('ai.promptTemplates.storageUnavailable')} />
+          )}
+          <Flex gap={12} wrap="wrap" align="center">
+            <Input.Search
+              aria-label={t('ai.promptTemplates.searchAria')}
+              allowClear
+              placeholder={t('ai.promptTemplates.searchPlaceholder')}
+              value={promptTemplateSearch}
+              onChange={(event) => setPromptTemplateSearch(event.target.value)}
+              style={{ flex: '1 1 260px' }}
+            />
+            <Segmented
+              value={promptTemplateMode}
+              options={promptTemplateFilterOptions}
+              onChange={(value) => setPromptTemplateMode(value as ChatMode | 
'all')}
+            />
+          </Flex>
+
+          <div>
+            <Typography.Text 
strong>{t('ai.promptTemplates.saveCurrent')}</Typography.Text>
+            <Flex gap={8} wrap="wrap" style={{ marginTop: 8 }}>
+              <Input
+                aria-label={t('ai.promptTemplates.titleInput')}
+                placeholder={t('ai.promptTemplates.titleInput')}
+                value={customTemplateTitle}
+                onChange={(event) => 
setCustomTemplateTitle(event.target.value)}
+                style={{ flex: '1 1 220px' }}
+              />
+              <Input
+                aria-label={t('ai.promptTemplates.tagsInput')}
+                placeholder={t('ai.promptTemplates.tagsPlaceholder')}
+                value={customTemplateTags}
+                onChange={(event) => setCustomTemplateTags(event.target.value)}
+                style={{ flex: '1 1 220px' }}
+              />
+              <Button
+                icon={<Plus size={16} />}
+                disabled={!inputValue.trim()}
+                onClick={handleSaveCurrentPromptTemplate}
+              >
+                {t('common.save')}
+              </Button>
+            </Flex>
+          </div>
+
+          <List
+            dataSource={visiblePromptTemplates}
+            locale={{ emptyText: t('ai.promptTemplates.empty') }}
+            renderItem={(template) => (
+              <List.Item
+                actions={[
+                  <Button
+                    key="replace"
+                    type="link"
+                    onClick={() => handleApplyPromptTemplate(template, 
'replace')}
+                  >
+                    {t('ai.promptTemplates.use')}
+                  </Button>,
+                  <Button
+                    key="append"
+                    type="link"
+                    onClick={() => handleApplyPromptTemplate(template, 
'append')}
+                  >
+                    {t('ai.promptTemplates.append')}
+                  </Button>,
+                  template.scope === 'custom' ? (
+                    <Popconfirm
+                      key="delete"
+                      title={t('ai.promptTemplates.deleteConfirm')}
+                      okText={t('common.delete')}
+                      cancelText={t('common.cancel')}
+                      onConfirm={() => handleDeletePromptTemplate(template)}
+                    >
+                      <Button
+                        danger
+                        type="text"
+                        size="small"
+                        aria-label={t('ai.promptTemplates.deleteAria', {
+                          title: template.title,
+                        })}
+                        icon={<Trash size={16} />}
+                      />
+                    </Popconfirm>
+                  ) : null,
+                ].filter(Boolean)}
+              >
+                <List.Item.Meta
+                  title={
+                    <Space size={8} wrap>
+                      <Typography.Text 
strong>{template.title}</Typography.Text>
+                      <Tag color={template.scope === 'custom' ? 'blue' : 
'default'}>
+                        {template.scope === 'custom'
+                          ? t('ai.promptTemplates.custom')
+                          : t('ai.promptTemplates.builtin')}
+                      </Tag>
+                      <Tag>{chatModeLabels[template.mode]}</Tag>
+                      {template.enhance && <Tag 
color="purple">{t('ai.promptEnhance')}</Tag>}
+                    </Space>
+                  }
+                  description={
+                    <Flex vertical gap={6}>
+                      {template.description && (
+                        <Typography.Text 
type="secondary">{template.description}</Typography.Text>
+                      )}
+                      <Typography.Text style={{ overflowWrap: 'anywhere' }}>
+                        {buildPromptTemplatePreview(template.body)}
+                      </Typography.Text>
+                      {template.tags.length > 0 && (
+                        <Space size={4} wrap>
+                          {template.tags.map((tag) => (
+                            <Tag key={tag}>{tag}</Tag>
+                          ))}
+                        </Space>
+                      )}
+                    </Flex>
+                  }
+                />
+              </List.Item>
+            )}
+          />
+        </Flex>
+      </Modal>
+
       <Modal
         title="AI 工具"
         open={toolModalOpen}
diff --git a/web/src/pages/ai/promptTemplates.test.ts 
b/web/src/pages/ai/promptTemplates.test.ts
new file mode 100644
index 000000000..34dd6831a
--- /dev/null
+++ b/web/src/pages/ai/promptTemplates.test.ts
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+  MAX_CUSTOM_PROMPT_TEMPLATES,
+  MAX_PROMPT_TEMPLATE_BODY_LENGTH,
+  PROMPT_TEMPLATE_STORAGE_KEY,
+  applyPromptTemplate,
+  buildPromptTemplatePreview,
+  builtinPromptTemplates,
+  deleteCustomPromptTemplate,
+  filterPromptTemplates,
+  loadPromptTemplateCatalog,
+  saveCustomPromptTemplate,
+  type PromptTemplate,
+} from './promptTemplates';
+
+describe('AI prompt templates', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    vi.restoreAllMocks();
+  });
+
+  it('loads builtin templates when no custom templates are stored', () => {
+    const catalog = loadPromptTemplateCatalog();
+
+    expect(catalog.storageAvailable).toBe(true);
+    expect(catalog.customCount).toBe(0);
+    expect(catalog.templates).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          id: 'builtin-diagnose-consumer-lag',
+          scope: 'builtin',
+          mode: 'diagnose',
+          enhance: true,
+        }),
+      ]),
+    );
+  });
+
+  it('saves a custom template before builtin templates', () => {
+    const result = saveCustomPromptTemplate(
+      {
+        title: '  自定义巡检  ',
+        description: '  每日巡检  ',
+        mode: 'diagnose',
+        enhance: true,
+        tags: 'Daily, Broker, daily',
+        body: '  检查 Broker 状态  ',
+      },
+      undefined,
+      1_787_878_787,
+    );
+
+    expect(result.ok).toBe(true);
+    const catalog = loadPromptTemplateCatalog();
+    expect(catalog.customCount).toBe(1);
+    expect(catalog.templates[0]).toMatchObject({
+      scope: 'custom',
+      title: '自定义巡检',
+      description: '每日巡检',
+      mode: 'diagnose',
+      enhance: true,
+      tags: ['daily', 'broker'],
+      body: '检查 Broker 状态',
+      createdAt: 1_787_878_787,
+    });
+    expect(catalog.templates[1].scope).toBe('builtin');
+  });
+
+  it('rejects empty titles or bodies without mutating storage', () => {
+    expect(saveCustomPromptTemplate({ title: ' ', body: 'Inspect lag' 
})).toEqual({
+      ok: false,
+      reason: 'empty_title',
+    });
+    expect(saveCustomPromptTemplate({ title: 'Inspect', body: ' ' })).toEqual({
+      ok: false,
+      reason: 'empty_body',
+    });
+
+    expect(localStorage.getItem(PROMPT_TEMPLATE_STORAGE_KEY)).toBeNull();
+  });
+
+  it('sanitizes malformed persisted templates during catalog loading', () => {
+    localStorage.setItem(
+      PROMPT_TEMPLATE_STORAGE_KEY,
+      JSON.stringify([
+        { id: 'legacy', title: 'Legacy', body: 'Use me', mode: 'unknown', 
tags: ['A'] },
+        { id: 'broken', title: '', body: '' },
+        'not-object',
+      ]),
+    );
+
+    const catalog = loadPromptTemplateCatalog();
+
+    expect(catalog.invalidCustomCount).toBe(2);
+    expect(catalog.templates[0]).toMatchObject({
+      id: 'custom-legacy',
+      scope: 'custom',
+      title: 'Legacy',
+      mode: 'chat',
+      tags: ['a'],
+    });
+  });
+
+  it('does not report templates dropped by the storage cap as invalid', () => {
+    const stored = Array.from({ length: MAX_CUSTOM_PROMPT_TEMPLATES + 3 }, (_, 
index) => ({
+      id: `capped-${index}`,
+      title: `Template ${index}`,
+      body: 'Summarise the consumer lag for this group',
+      mode: 'chat',
+      tags: ['general'],
+    }));
+    localStorage.setItem(PROMPT_TEMPLATE_STORAGE_KEY, JSON.stringify(stored));
+
+    const catalog = loadPromptTemplateCatalog();
+
+    expect(catalog.invalidCustomCount).toBe(0);
+    expect(catalog.customCount).toBe(MAX_CUSTOM_PROMPT_TEMPLATES);
+  });
+
+  it('bounds custom template count and body size', () => {
+    for (let index = 0; index < MAX_CUSTOM_PROMPT_TEMPLATES + 2; index += 1) {
+      saveCustomPromptTemplate({
+        title: `Template ${index}`,
+        body: `${index}-${'x'.repeat(MAX_PROMPT_TEMPLATE_BODY_LENGTH + 20)}`,
+      });
+    }
+
+    const custom = loadPromptTemplateCatalog().templates.filter(
+      (template) => template.scope === 'custom',
+    );
+    expect(custom).toHaveLength(MAX_CUSTOM_PROMPT_TEMPLATES);
+    expect(custom[0].title).toBe(`Template ${MAX_CUSTOM_PROMPT_TEMPLATES + 
1}`);
+    expect(custom[0].body).toHaveLength(MAX_PROMPT_TEMPLATE_BODY_LENGTH);
+  });
+
+  it('deletes only custom templates and leaves builtin templates available', 
() => {
+    const saved = saveCustomPromptTemplate({ title: 'Temporary', body: 
'Inspect temporary state' });
+    expect(saved.template).toBeDefined();
+
+    expect(deleteCustomPromptTemplate(saved.template!.id)).toBe(true);
+
+    const catalog = loadPromptTemplateCatalog();
+    expect(catalog.customCount).toBe(0);
+    expect(catalog.templates).toHaveLength(builtinPromptTemplates.length);
+    expect(localStorage.getItem(PROMPT_TEMPLATE_STORAGE_KEY)).toBeNull();
+  });
+
+  it('filters templates by keyword and mode', () => {
+    const templates: PromptTemplate[] = [
+      {
+        id: 'one',
+        scope: 'builtin',
+        title: 'Broker health',
+        description: '',
+        mode: 'diagnose',
+        enhance: true,
+        tags: ['broker'],
+        body: 'Inspect broker status',
+      },
+      {
+        id: 'two',
+        scope: 'builtin',
+        title: 'Topic change',
+        description: '',
+        mode: 'manage',
+        enhance: true,
+        tags: ['topic'],
+        body: 'Change topic',
+      },
+    ];
+
+    expect(filterPromptTemplates(templates, 'broker', 'all').map((item) => 
item.id)).toEqual([
+      'one',
+    ]);
+    expect(filterPromptTemplates(templates, '', 'manage').map((item) => 
item.id)).toEqual(['two']);
+  });
+
+  it('applies a template by replacing or appending to the current prompt', () 
=> {
+    const template = builtinPromptTemplates[0];
+
+    expect(applyPromptTemplate(template, 'current', 
'replace')).toBe(template.body);
+    expect(applyPromptTemplate(template, 'current', 
'append')).toBe(`current\n\n${template.body}`);
+    expect(applyPromptTemplate(template, '   ', 'append')).toBe(template.body);
+  });
+
+  it('returns compact previews for multiline template bodies', () => {
+    expect(buildPromptTemplatePreview('line 1\n\nline 2')).toBe('line 1 line 
2');
+    expect(buildPromptTemplatePreview('x'.repeat(20), 8)).toBe('xxxxxxxx...');
+  });
+
+  it('reports storage as unavailable when localStorage throws', () => {
+    vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+      throw new DOMException('blocked', 'SecurityError');
+    });
+
+    expect(loadPromptTemplateCatalog()).toMatchObject({
+      customCount: 0,
+      storageAvailable: false,
+    });
+    expect(saveCustomPromptTemplate({ title: 'Blocked', body: 'Blocked body' 
})).toEqual({
+      ok: false,
+      reason: 'storage_unavailable',
+    });
+  });
+});
diff --git a/web/src/pages/ai/promptTemplates.ts 
b/web/src/pages/ai/promptTemplates.ts
new file mode 100644
index 000000000..674daa395
--- /dev/null
+++ b/web/src/pages/ai/promptTemplates.ts
@@ -0,0 +1,401 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ChatMode } from './chatDraft';
+
+export type PromptTemplateScope = 'builtin' | 'custom';
+export type PromptTemplateApplyMode = 'replace' | 'append';
+
+export interface PromptTemplate {
+  id: string;
+  scope: PromptTemplateScope;
+  i18nKey?: string;
+  title: string;
+  description: string;
+  mode: ChatMode;
+  enhance: boolean;
+  tags: string[];
+  body: string;
+  createdAt?: number;
+  updatedAt?: number;
+}
+
+export interface PromptTemplateDraft {
+  title: string;
+  description?: string;
+  mode?: ChatMode;
+  enhance?: boolean;
+  tags?: string[] | string;
+  body: string;
+}
+
+export interface PromptTemplateCatalog {
+  templates: PromptTemplate[];
+  customCount: number;
+  storageAvailable: boolean;
+  invalidCustomCount: number;
+}
+
+export interface SavePromptTemplateResult {
+  ok: boolean;
+  template?: PromptTemplate;
+  reason?: 'empty_title' | 'empty_body' | 'storage_unavailable';
+}
+
+type Translate = (key: string) => string;
+
+export const PROMPT_TEMPLATE_STORAGE_KEY = 
'rocketmq-studio-ai-prompt-templates';
+export const MAX_CUSTOM_PROMPT_TEMPLATES = 20;
+export const MAX_PROMPT_TEMPLATE_BODY_LENGTH = 6000;
+export const MAX_PROMPT_TEMPLATE_TITLE_LENGTH = 80;
+export const MAX_PROMPT_TEMPLATE_DESCRIPTION_LENGTH = 200;
+export const MAX_PROMPT_TEMPLATE_TAGS = 6;
+export const MAX_PROMPT_TEMPLATE_TAG_LENGTH = 24;
+
+const CHAT_MODES = new Set<ChatMode>(['chat', 'diagnose', 'manage', 'query']);
+
+export const builtinPromptTemplates: PromptTemplate[] = [
+  {
+    id: 'builtin-diagnose-consumer-lag',
+    scope: 'builtin',
+    i18nKey: 'ai.promptTemplates.consumerLag',
+    title: '消费延迟诊断',
+    description: '排查消费堆积、在线客户端、订阅和 Broker 进度返回状态。',
+    mode: 'diagnose',
+    enhance: true,
+    tags: ['consumer', 'lag', 'diagnose'],
+    body: [
+      '请诊断当前 RocketMQ 实例中的消费延迟问题。',
+      '',
+      '检查范围:',
+      '1. 找出堆积最高的消费组和 Topic。',
+      '2. 对比消费组在线客户端、订阅关系、重试队列和队列级 offset。',
+      '3. 判断是生产突增、消费者离线、订阅漂移、Broker 进度不可查,还是单队列热点。',
+      '4. 给出可执行的恢复步骤,并标注哪些步骤只读、哪些步骤会修改集群状态。',
+    ].join('\n'),
+  },
+  {
+    id: 'builtin-query-message-trace',
+    scope: 'builtin',
+    i18nKey: 'ai.promptTemplates.messageTrace',
+    title: '消息轨迹排查',
+    description: '按 Message ID 或 Key 收集发送、存储、消费和重试线索。',
+    mode: 'query',
+    enhance: true,
+    tags: ['message', 'trace', 'query'],
+    body: [
+      '请协助排查一条消息的完整轨迹。',
+      '',
+      '已知信息:',
+      '- Message ID:',
+      '- Topic:',
+      '- Key 或业务单号:',
+      '- 大致发送时间:',
+      '',
+      '请先说明需要哪些查询条件,然后按发送结果、Broker 存储、消费结果、重试/DLQ 四部分输出结论。',
+    ].join('\n'),
+  },
+  {
+    id: 'builtin-manage-topic-change',
+    scope: 'builtin',
+    i18nKey: 'ai.promptTemplates.topicChange',
+    title: 'Topic 变更预案',
+    description: '生成 Topic 队列、权限、TTL 或保留策略变更前的检查清单。',
+    mode: 'manage',
+    enhance: true,
+    tags: ['topic', 'change', 'precheck'],
+    body: [
+      '请为一次 RocketMQ Topic 变更生成执行预案。',
+      '',
+      '变更目标:',
+      '- Topic:',
+      '- 期望调整:',
+      '- 影响窗口:',
+      '',
+      '请输出:变更前检查、风险判断、执行步骤、回滚步骤、验证方式。涉及写操作时先列出需要人工确认的命令或接口。',
+    ].join('\n'),
+  },
+  {
+    id: 'builtin-diagnose-broker-health',
+    scope: 'builtin',
+    i18nKey: 'ai.promptTemplates.brokerHealth',
+    title: 'Broker 健康巡检',
+    description: '汇总 Broker 可用性、磁盘水位、TPS、队列分布和异常告警。',
+    mode: 'diagnose',
+    enhance: true,
+    tags: ['broker', 'health', 'ops'],
+    body: [
+      '请对当前 RocketMQ 集群做一次 Broker 健康巡检。',
+      '',
+      '请关注:',
+      '1. Broker 可用性和主从状态。',
+      '2. 磁盘水位、TPS、消息量和异常告警。',
+      '3. Topic 队列分布是否存在热点或倾斜。',
+      '4. 最近是否有失败的告警通知或未确认系统告警。',
+      '',
+      '输出时请区分“已验证事实”“需要进一步查询”“建议处理动作”。',
+    ].join('\n'),
+  },
+  {
+    id: 'builtin-chat-release-summary',
+    scope: 'builtin',
+    i18nKey: 'ai.promptTemplates.opsSummary',
+    title: '运维变更摘要',
+    description: '把已执行的查询结果整理成面向值班和复盘的摘要。',
+    mode: 'chat',
+    enhance: false,
+    tags: ['summary', 'handoff'],
+    body: [
+      '请把本次 RocketMQ 运维排查过程整理成简洁摘要。',
+      '',
+      '摘要结构:',
+      '- 背景:',
+      '- 已执行查询:',
+      '- 关键发现:',
+      '- 已完成操作:',
+      '- 未完成风险:',
+      '- 下一步:',
+    ].join('\n'),
+  },
+  {
+    id: 'builtin-query-acl-risk',
+    scope: 'builtin',
+    i18nKey: 'ai.promptTemplates.aclRisk',
+    title: 'ACL 风险核对',
+    description: '检查用户、策略、实例绑定和高危权限是否符合预期。',
+    mode: 'query',
+    enhance: true,
+    tags: ['acl', 'security', 'query'],
+    body: [
+      '请核对当前 RocketMQ Studio 的 ACL 风险。',
+      '',
+      '请检查:',
+      '1. 是否存在管理员权限、通配资源或写权限过宽的账号。',
+      '2. 用户和实例绑定是否符合最小权限。',
+      '3. 云厂商实例与 Apache 实例的 ACL 字段差异。',
+      '4. 哪些发现需要立即处理,哪些只是建议优化。',
+    ].join('\n'),
+  },
+];
+
+type PromptTemplateStorage = Pick<Storage, 'getItem' | 'setItem' | 
'removeItem'>;
+
+const isRecord = (value: unknown): value is Record<string, unknown> =>
+  typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const getStorage = (): PromptTemplateStorage | undefined => {
+  if (typeof localStorage === 'undefined') return undefined;
+  return localStorage;
+};
+
+const nowMs = () => Date.now();
+
+const newTemplateId = (): string => {
+  const random =
+    typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+      ? crypto.randomUUID()
+      : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+  return `custom-${random}`;
+};
+
+const normalizeText = (value: unknown, maxLength: number): string => {
+  const text = typeof value === 'string' ? value.trim() : '';
+  return text.length > maxLength ? text.slice(0, maxLength) : text;
+};
+
+const normalizeMode = (value: unknown): ChatMode =>
+  typeof value === 'string' && CHAT_MODES.has(value as ChatMode) ? (value as 
ChatMode) : 'chat';
+
+const normalizeTags = (value: unknown): string[] => {
+  const source = Array.isArray(value) ? value : typeof value === 'string' ? 
value.split(',') : [];
+  const normalized = source
+    .map((tag) => normalizeText(tag, 
MAX_PROMPT_TEMPLATE_TAG_LENGTH).toLowerCase())
+    .filter(Boolean);
+  return [...new Set(normalized)].slice(0, MAX_PROMPT_TEMPLATE_TAGS);
+};
+
+const sanitizeCustomTemplate = (value: unknown, fallbackTime = nowMs()): 
PromptTemplate | null => {
+  if (!isRecord(value)) return null;
+  const title = normalizeText(value.title, MAX_PROMPT_TEMPLATE_TITLE_LENGTH);
+  const body = normalizeText(value.body, MAX_PROMPT_TEMPLATE_BODY_LENGTH);
+  if (!title || !body) return null;
+  const id = normalizeText(value.id, 120) || newTemplateId();
+  return {
+    id: id.startsWith('custom-') ? id : `custom-${id}`,
+    scope: 'custom',
+    title,
+    description: normalizeText(value.description, 
MAX_PROMPT_TEMPLATE_DESCRIPTION_LENGTH),
+    mode: normalizeMode(value.mode),
+    enhance: value.enhance === true,
+    tags: normalizeTags(value.tags),
+    body,
+    createdAt: typeof value.createdAt === 'number' ? value.createdAt : 
fallbackTime,
+    updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : 
fallbackTime,
+  };
+};
+
+const translatedText = (
+  translate: Translate | undefined,
+  key: string | undefined,
+  field: 'title' | 'description' | 'body',
+  fallback: string,
+): string => {
+  if (!translate || !key) return fallback;
+  const translated = translate(`${key}.${field}`);
+  return translated === `${key}.${field}` ? fallback : translated;
+};
+
+function getBuiltinPromptTemplates(translate?: Translate): PromptTemplate[] {
+  return builtinPromptTemplates.map((template) => ({
+    ...template,
+    title: translatedText(translate, template.i18nKey, 'title', 
template.title),
+    description: translatedText(translate, template.i18nKey, 'description', 
template.description),
+    body: translatedText(translate, template.i18nKey, 'body', template.body),
+  }));
+}
+
+function readCustomPromptTemplates(storage = getStorage()): {
+  templates: PromptTemplate[];
+  storageAvailable: boolean;
+  invalidCustomCount: number;
+} {
+  if (!storage) return { templates: [], storageAvailable: false, 
invalidCustomCount: 0 };
+  try {
+    const raw = storage.getItem(PROMPT_TEMPLATE_STORAGE_KEY);
+    if (!raw) return { templates: [], storageAvailable: true, 
invalidCustomCount: 0 };
+    const parsed = JSON.parse(raw) as unknown;
+    const items = Array.isArray(parsed) ? parsed : [];
+    const sanitized = items
+      .map((item) => sanitizeCustomTemplate(item))
+      .filter((item): item is PromptTemplate => item !== null);
+    return {
+      templates: sanitized.slice(0, MAX_CUSTOM_PROMPT_TEMPLATES),
+      storageAvailable: true,
+      // Only entries the sanitizer rejected are invalid: valid templates 
dropped by the
+      // cap are a storage limit, not corrupt data, and must not be counted 
here.
+      invalidCustomCount: items.length - sanitized.length,
+    };
+  } catch {
+    return { templates: [], storageAvailable: false, invalidCustomCount: 0 };
+  }
+}
+
+function writeCustomPromptTemplates(templates: PromptTemplate[], storage = 
getStorage()): boolean {
+  if (!storage) return false;
+  try {
+    storage.setItem(
+      PROMPT_TEMPLATE_STORAGE_KEY,
+      JSON.stringify(templates.slice(0, MAX_CUSTOM_PROMPT_TEMPLATES)),
+    );
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export function loadPromptTemplateCatalog(
+  storage = getStorage(),
+  translate?: Translate,
+): PromptTemplateCatalog {
+  const custom = readCustomPromptTemplates(storage);
+  return {
+    templates: [...custom.templates, ...getBuiltinPromptTemplates(translate)],
+    customCount: custom.templates.length,
+    storageAvailable: custom.storageAvailable,
+    invalidCustomCount: custom.invalidCustomCount,
+  };
+}
+
+export function saveCustomPromptTemplate(
+  draft: PromptTemplateDraft,
+  storage = getStorage(),
+  timestamp = nowMs(),
+): SavePromptTemplateResult {
+  const title = normalizeText(draft.title, MAX_PROMPT_TEMPLATE_TITLE_LENGTH);
+  const body = normalizeText(draft.body, MAX_PROMPT_TEMPLATE_BODY_LENGTH);
+  if (!title) return { ok: false, reason: 'empty_title' };
+  if (!body) return { ok: false, reason: 'empty_body' };
+
+  const current = readCustomPromptTemplates(storage);
+  if (!current.storageAvailable) return { ok: false, reason: 
'storage_unavailable' };
+
+  const template: PromptTemplate = {
+    id: newTemplateId(),
+    scope: 'custom',
+    title,
+    description: normalizeText(draft.description, 
MAX_PROMPT_TEMPLATE_DESCRIPTION_LENGTH),
+    mode: normalizeMode(draft.mode),
+    enhance: draft.enhance === true,
+    tags: normalizeTags(draft.tags),
+    body,
+    createdAt: timestamp,
+    updatedAt: timestamp,
+  };
+  const next = [template, ...current.templates].slice(0, 
MAX_CUSTOM_PROMPT_TEMPLATES);
+  if (!writeCustomPromptTemplates(next, storage)) {
+    return { ok: false, reason: 'storage_unavailable' };
+  }
+  return { ok: true, template };
+}
+
+export function deleteCustomPromptTemplate(id: string, storage = 
getStorage()): boolean {
+  const current = readCustomPromptTemplates(storage);
+  if (!current.storageAvailable) return false;
+  const next = current.templates.filter((template) => template.id !== id);
+  if (next.length === current.templates.length) return true;
+  if (next.length === 0) {
+    try {
+      storage?.removeItem(PROMPT_TEMPLATE_STORAGE_KEY);
+      return true;
+    } catch {
+      return false;
+    }
+  }
+  return writeCustomPromptTemplates(next, storage);
+}
+
+export function applyPromptTemplate(
+  template: PromptTemplate,
+  currentText: string,
+  mode: PromptTemplateApplyMode,
+): string {
+  if (mode === 'replace') return template.body;
+  const current = currentText.trim();
+  return current ? `${current}\n\n${template.body}` : template.body;
+}
+
+export function filterPromptTemplates(
+  templates: PromptTemplate[],
+  search: string,
+  mode?: ChatMode | 'all',
+): PromptTemplate[] {
+  const keyword = search.trim().toLowerCase();
+  return templates.filter((template) => {
+    const modeMatched = !mode || mode === 'all' || template.mode === mode;
+    if (!modeMatched) return false;
+    if (!keyword) return true;
+    return [template.title, template.description, template.body, 
...template.tags]
+      .join('\n')
+      .toLowerCase()
+      .includes(keyword);
+  });
+}
+
+export function buildPromptTemplatePreview(body: string, maxLength = 160): 
string {
+  const compact = body.replace(/\s+/g, ' ').trim();
+  return compact.length > maxLength ? `${compact.slice(0, maxLength)}...` : 
compact;
+}

Reply via email to