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

commit ca7adae4f6dbc5ffa050264a99e34e152df14d6c
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:25:25 2026 +0800

    feat: add LLM Settings configuration page (#483)
    
    Add LLM settings configuration page (provider, apiKey, model, temperature, 
etc.).
---
 web/src/App.tsx                      |   2 +
 web/src/api/llm.test.ts              | 135 ++++++
 web/src/api/llm.ts                   |  67 +++
 web/src/i18n/translations.ts         |  99 +++++
 web/src/pages/studio/LlmSettings.tsx | 781 +++++++++++++++++++++++++++++++++++
 5 files changed, 1084 insertions(+)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index 4c65e7e0..593295ac 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts';
 import AuditPage from './pages/ops/audit';
 import AiPage from './pages/ai';
 import SettingsPage from './pages/settings';
+import LlmSettingsPage from './pages/studio/LlmSettings';
 import ProxyPage from './pages/studio/Proxy';
 import LiteTopicPage from './pages/studio/LiteTopic';
 import GroupManagementPage from './pages/studio/GroupManagement';
@@ -64,6 +65,7 @@ function App() {
         <Route path="ops/audit" element={<AuditPage />} />
         <Route path="ai" element={<AiPage />} />
         <Route path="settings" element={<SettingsPage />} />
+        <Route path="studio/llm-settings" element={<LlmSettingsPage />} />
         <Route path="studio/proxy" element={<ProxyPage />} />
         <Route path="studio/lite-topic" element={<LiteTopicPage />} />
         <Route path="studio/group-management" element={<GroupManagementPage 
/>} />
diff --git a/web/src/api/llm.test.ts b/web/src/api/llm.test.ts
new file mode 100644
index 00000000..63bcaf50
--- /dev/null
+++ b/web/src/api/llm.test.ts
@@ -0,0 +1,135 @@
+/*
+ * 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 MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import {
+  getLlmConfig,
+  saveLlmConfig,
+  testLlmConnection,
+  getLlmModels,
+  type LlmConfig,
+} from './llm';
+
+const mock = new MockAdapter(client);
+
+const sampleConfig: LlmConfig = {
+  provider: 'openai',
+  apiKey: 'sk-test-key-1234',
+  apiBase: 'https://api.openai.com/v1',
+  model: 'gpt-4o',
+  maxTokens: 4096,
+  temperature: 0.7,
+  enabled: true,
+};
+
+describe('LLM API', () => {
+  beforeEach(() => {
+    mock.reset();
+    vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+  });
+
+  afterEach(() => {
+    mock.reset();
+    vi.unstubAllGlobals();
+  });
+
+  it('fetches LLM config', async () => {
+    mock.onGet('/llm/config').reply(200, sampleConfig);
+
+    const result = await getLlmConfig();
+    expect(result).toEqual(sampleConfig);
+    expect(result.provider).toBe('openai');
+    expect(result.enabled).toBe(true);
+  });
+
+  it('saves LLM config and returns success result', async () => {
+    mock.onPost('/llm/config').reply((config) => {
+      const body = JSON.parse(config.data);
+      expect(body.provider).toBe('openai');
+      expect(body.model).toBe('gpt-4o');
+      return [200, { status: 0, msg: 'saved' }];
+    });
+
+    const result = await saveLlmConfig(sampleConfig);
+    expect(result.status).toBe(0);
+    expect(result.msg).toBe('saved');
+  });
+
+  it('tests LLM connection with success', async () => {
+    mock.onPost('/llm/config/test').reply((config) => {
+      const body = JSON.parse(config.data);
+      expect(body.apiKey).toBe('sk-test-key-1234');
+      return [200, { status: 0, msg: 'Connection successful' }];
+    });
+
+    const result = await testLlmConnection(sampleConfig);
+    expect(result.status).toBe(0);
+    expect(result.msg).toBe('Connection successful');
+  });
+
+  it('tests LLM connection with failure', async () => {
+    mock.onPost('/llm/config/test').reply(200, {
+      status: 1,
+      errMsg: 'Invalid API Key',
+    });
+
+    const result = await testLlmConnection(sampleConfig);
+    expect(result.status).toBe(1);
+    expect(result.errMsg).toBe('Invalid API Key');
+  });
+
+  it('fetches available models', async () => {
+    const models = [
+      { id: 'gpt-4o', name: 'GPT-4o' },
+      { id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
+    ];
+    mock.onGet('/llm/models').reply(200, { status: 0, data: models });
+
+    const result = await getLlmModels();
+    expect(result.status).toBe(0);
+    expect(result.data).toHaveLength(2);
+    expect(result.data![0].id).toBe('gpt-4o');
+  });
+
+  it('handles empty models list', async () => {
+    mock.onGet('/llm/models').reply(200, { status: 0, data: [] });
+
+    const result = await getLlmModels();
+    expect(result.status).toBe(0);
+    expect(result.data).toHaveLength(0);
+  });
+
+  it('saves config with Azure extra fields', async () => {
+    const azureConfig: LlmConfig = {
+      ...sampleConfig,
+      provider: 'azure',
+      deploymentName: 'my-gpt4',
+      apiVersion: '2024-02-15-preview',
+    };
+    mock.onPost('/llm/config').reply((config) => {
+      const body = JSON.parse(config.data);
+      expect(body.provider).toBe('azure');
+      expect(body.deploymentName).toBe('my-gpt4');
+      expect(body.apiVersion).toBe('2024-02-15-preview');
+      return [200, { status: 0 }];
+    });
+
+    await saveLlmConfig(azureConfig);
+  });
+});
diff --git a/web/src/api/llm.ts b/web/src/api/llm.ts
new file mode 100644
index 00000000..871366ac
--- /dev/null
+++ b/web/src/api/llm.ts
@@ -0,0 +1,67 @@
+/*
+ * 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 client from './client';
+
+export interface LlmConfig {
+  provider: string;
+  apiKey: string;
+  apiBase: string;
+  model: string;
+  maxTokens: number;
+  temperature: number;
+  enabled: boolean;
+  deploymentName?: string;
+  apiVersion?: string;
+  awsRegion?: string;
+}
+
+export interface LlmTestResult {
+  status: number;
+  msg?: string;
+  errMsg?: string;
+}
+
+export interface LlmModelItem {
+  id?: string;
+  name?: string;
+}
+
+export interface LlmModelsResult {
+  status: number;
+  data?: LlmModelItem[];
+}
+
+export async function getLlmConfig(): Promise<LlmConfig> {
+  const res = await client.get('/llm/config');
+  return res.data;
+}
+
+export async function saveLlmConfig(config: LlmConfig): Promise<LlmTestResult> 
{
+  const res = await client.post('/llm/config', config);
+  return res.data;
+}
+
+export async function testLlmConnection(config: LlmConfig): 
Promise<LlmTestResult> {
+  const res = await client.post('/llm/config/test', config);
+  return res.data;
+}
+
+export async function getLlmModels(): Promise<LlmModelsResult> {
+  const res = await client.get('/llm/models');
+  return res.data;
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index c4ad57c4..dd619874 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -955,6 +955,105 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'common.na': { zh: 'N/A', en: 'N/A' },
   'common.default': { zh: '默认', en: 'Default' },
 
+  // ─── LLM Settings ──────────────────────────────────────────
+  'llm.title': { zh: 'AI助手配置', en: 'AI Assistant Settings' },
+  'llm.subtitle': {
+    zh: '配置大语言模型提供商和参数,启用后可通过 ⌘K 呼出AI助手',
+    en: 'Configure LLM provider and parameters. Enable to invoke AI assistant 
via ⌘K',
+  },
+  'llm.enable': { zh: '启用AI助手', en: 'Enable AI Assistant' },
+  'llm.on': { zh: '开', en: 'On' },
+  'llm.off': { zh: '关', en: 'Off' },
+  'llm.selectProvider': { zh: '选择大模型提供商', en: 'Select LLM Provider' },
+  'llm.connectionConfig': { zh: '连接配置', en: 'Connection Configuration' },
+  'llm.modelParams': { zh: '模型参数', en: 'Model Parameters' },
+  'llm.apiKey': { zh: 'API Key', en: 'API Key' },
+  'llm.apiKeyRequired': { zh: '请输入API Key', en: 'Please enter API Key' },
+  'llm.apiKeyEncrypted': {
+    zh: 'API Key 将加密存储在服务端',
+    en: 'API Key will be encrypted and stored on the server',
+  },
+  'llm.apiKeyPlaceholder': { zh: 'sk-xxxxxxxxxxxxxxxx', en: 
'sk-xxxxxxxxxxxxxxxx' },
+  'llm.apiKeyNoRequired': {
+    zh: '本地服务无需API Key',
+    en: 'No API Key required for local service',
+  },
+  'llm.apiBase': { zh: 'API Base URL', en: 'API Base URL' },
+  'llm.apiBaseRequired': { zh: '请输入API地址', en: 'Please enter API URL' },
+  'llm.apiBaseCustom': {
+    zh: '留空使用默认地址,可自定义',
+    en: 'Leave empty for default URL, or customize',
+  },
+  'llm.apiBaseRequiredHint': { zh: '必填:自定义服务地址', en: 'Required: Custom service 
URL' },
+  'llm.deploymentName': { zh: '部署名称 (Deployment Name)', en: 'Deployment Name' 
},
+  'llm.deploymentNameRequired': {
+    zh: '请输入Azure部署名称',
+    en: 'Please enter Azure deployment name',
+  },
+  'llm.apiVersion': { zh: 'API 版本', en: 'API Version' },
+  'llm.apiVersionRequired': { zh: '请输入API版本', en: 'Please enter API version' },
+  'llm.awsRegion': { zh: 'AWS 区域', en: 'AWS Region' },
+  'llm.awsRegionRequired': { zh: '请选择AWS区域', en: 'Please select AWS region' },
+  'llm.model': { zh: '模型名称', en: 'Model Name' },
+  'llm.modelRequired': { zh: '请选择或输入模型名称', en: 'Please select or enter model 
name' },
+  'llm.modelExtra': {
+    zh: '实时从 LLM 提供商获取可用模型列表,也可直接输入自定义模型名',
+    en: 'Fetch available models from LLM provider in real-time, or enter a 
custom model name',
+  },
+  'llm.modelsLoading': { zh: '正在获取模型列表...', en: 'Loading models...' },
+  'llm.modelsNotFound': {
+    zh: '未获取到模型,请确认已保存配置并测试连接',
+    en: 'No models found. Please save config and test connection first',
+  },
+  'llm.maxTokens': { zh: '最大 Token 数', en: 'Max Tokens' },
+  'llm.temperature': { zh: '温度 (Temperature)', en: 'Temperature' },
+  'llm.temperatureExtra': {
+    zh: '值越低输出越确定,越高越随机',
+    en: 'Lower values = more deterministic, higher = more random',
+  },
+  'llm.saveConfig': { zh: '保存配置', en: 'Save Config' },
+  'llm.testConnection': { zh: '连接测试', en: 'Test Connection' },
+  'llm.testing': { zh: '测试中...', en: 'Testing...' },
+  'llm.securityNote': {
+    zh: 'API Key 将加密存储,连接测试仅验证Key有效性',
+    en: 'API Key is encrypted. Connection test only verifies key validity',
+  },
+  'llm.testSuccess': { zh: '连接测试成功', en: 'Connection test successful' },
+  'llm.testSuccessMsg': {
+    zh: '连接测试成功!API Key 有效。',
+    en: 'Connection test successful! API Key is valid.',
+  },
+  'llm.testFailed': { zh: '连接测试失败', en: 'Connection test failed' },
+  'llm.testFailedMsg': {
+    zh: '连接测试失败,请检查配置。',
+    en: 'Connection test failed. Please check configuration.',
+  },
+  'llm.testError': { zh: '连接测试失败: ', en: 'Connection test failed: ' },
+  'llm.saveSuccess': { zh: 'AI助手配置已保存', en: 'AI assistant config saved' },
+  'llm.saveFailed': { zh: '保存配置失败', en: 'Failed to save config' },
+  'llm.formIncomplete': { zh: '请检查表单填写是否完整', en: 'Please check form 
completeness' },
+  'llm.loadFailed': { zh: '加载AI配置失败', en: 'Failed to load AI config' },
+  'llm.providerOpenai': { zh: 'OpenAI', en: 'OpenAI' },
+  'llm.providerOpenaiDesc': { zh: 'GPT-4o / GPT-4 / GPT-3.5', en: 'GPT-4o / 
GPT-4 / GPT-3.5' },
+  'llm.providerAzure': { zh: 'Azure OpenAI', en: 'Azure OpenAI' },
+  'llm.providerAzureDesc': { zh: 'Azure 托管的 OpenAI 服务', en: 'Azure-hosted 
OpenAI service' },
+  'llm.providerDeepseek': { zh: 'DeepSeek', en: 'DeepSeek' },
+  'llm.providerDeepseekDesc': { zh: 'DeepSeek-V3 / DeepSeek-R1', en: 
'DeepSeek-V3 / DeepSeek-R1' },
+  'llm.providerTongyi': { zh: '通义千问', en: 'Tongyi Qwen' },
+  'llm.providerTongyiDesc': {
+    zh: 'Qwen-Max / Qwen-Plus / Qwen-Turbo',
+    en: 'Qwen-Max / Qwen-Plus / Qwen-Turbo',
+  },
+  'llm.providerOllama': { zh: 'Ollama', en: 'Ollama' },
+  'llm.providerOllamaDesc': {
+    zh: '本地部署的开源模型服务',
+    en: 'Locally deployed open-source model service',
+  },
+  'llm.providerBedrock': { zh: 'AWS Bedrock', en: 'AWS Bedrock' },
+  'llm.providerBedrockDesc': {
+    zh: 'Claude / Titan / Llama (AWS)',
+    en: 'Claude / Titan / Llama (AWS)',
+  },
   // ─── BrokerCluster ───
 };
 
diff --git a/web/src/pages/studio/LlmSettings.tsx 
b/web/src/pages/studio/LlmSettings.tsx
new file mode 100644
index 00000000..74f34edf
--- /dev/null
+++ b/web/src/pages/studio/LlmSettings.tsx
@@ -0,0 +1,781 @@
+/*
+ * 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 { useState, useRef, useCallback } from 'react';
+import {
+  Form,
+  Input,
+  Select,
+  Slider,
+  Switch,
+  Button,
+  Card,
+  Space,
+  Typography,
+  Divider,
+  Alert,
+  Row,
+  Col,
+  App,
+} from 'antd';
+import {
+  FloppyDisk,
+  Lightning,
+  Cloud,
+  Globe,
+  Key,
+  ShieldCheck,
+  CheckCircle,
+  XCircle,
+} from '@phosphor-icons/react';
+import PageHeader from '../../components/PageHeader';
+import { useLang } from '../../i18n/LangContext';
+import {
+  getLlmConfig,
+  saveLlmConfig,
+  testLlmConnection,
+  getLlmModels,
+  type LlmConfig,
+} from '../../api/llm';
+
+const { Text } = Typography;
+
+// Fallback models when API fetch fails
+const FALLBACK_MODELS: Record<string, string[]> = {
+  openai: ['gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
+  azure: ['gpt-4o', 'gpt-4', 'gpt-3.5-turbo'],
+  deepseek: ['deepseek-chat', 'deepseek-reasoner'],
+  tongyi: ['qwen-max', 'qwen-plus', 'qwen-turbo'],
+  ollama: ['llama3', 'mistral', 'gemma2', 'qwen2.5'],
+  bedrock: ['anthropic.claude-3-sonnet', 'anthropic.claude-3-haiku', 
'meta.llama3-70b'],
+};
+
+interface ProviderDef {
+  key: string;
+  label: string;
+  icon: string;
+  color: string;
+  descKey: string;
+  defaultBaseUrl: string;
+  defaultModel: string;
+  requireApiKey: boolean;
+  requireBaseUrl: boolean;
+  extraFields?: string[];
+}
+
+const PROVIDERS: ProviderDef[] = [
+  {
+    key: 'openai',
+    label: 'OpenAI',
+    icon: '🤖',
+    color: '#10a37f',
+    descKey: 'llm.providerOpenaiDesc',
+    defaultBaseUrl: 'https://api.openai.com/v1',
+    defaultModel: 'gpt-4o',
+    requireApiKey: true,
+    requireBaseUrl: false,
+  },
+  {
+    key: 'azure',
+    label: 'Azure OpenAI',
+    icon: '☁️',
+    color: '#0078d4',
+    descKey: 'llm.providerAzureDesc',
+    defaultBaseUrl: '',
+    defaultModel: 'gpt-4o',
+    requireApiKey: true,
+    requireBaseUrl: true,
+    extraFields: ['deploymentName', 'apiVersion'],
+  },
+  {
+    key: 'deepseek',
+    label: 'DeepSeek',
+    icon: '🔍',
+    color: '#4d6bfe',
+    descKey: 'llm.providerDeepseekDesc',
+    defaultBaseUrl: 'https://api.deepseek.com/v1',
+    defaultModel: 'deepseek-chat',
+    requireApiKey: true,
+    requireBaseUrl: false,
+  },
+  {
+    key: 'tongyi',
+    label: '通义千问',
+    icon: '🧠',
+    color: '#6236ff',
+    descKey: 'llm.providerTongyiDesc',
+    defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
+    defaultModel: 'qwen-max',
+    requireApiKey: true,
+    requireBaseUrl: false,
+  },
+  {
+    key: 'ollama',
+    label: 'Ollama',
+    icon: '🦙',
+    color: '#6e40c9',
+    descKey: 'llm.providerOllamaDesc',
+    defaultBaseUrl: 'http://localhost:11434/v1',
+    defaultModel: 'llama3',
+    requireApiKey: false,
+    requireBaseUrl: true,
+  },
+  {
+    key: 'bedrock',
+    label: 'AWS Bedrock',
+    icon: '☁️',
+    color: '#ff9900',
+    descKey: 'llm.providerBedrockDesc',
+    defaultBaseUrl: '',
+    defaultModel: 'anthropic.claude-3-sonnet',
+    requireApiKey: true,
+    requireBaseUrl: false,
+    extraFields: ['awsRegion'],
+  },
+];
+
+interface TestResult {
+  success: boolean;
+  msg: string;
+}
+
+const LlmSettingsPage: React.FC = () => {
+  const { t } = useLang();
+  const { message } = App.useApp();
+
+  const [form] = Form.useForm();
+  const [loading, setLoading] = useState(false);
+  const [testLoading, setTestLoading] = useState(false);
+  const [testResult, setTestResult] = useState<TestResult | null>(null);
+  const [enabled, setEnabled] = useState(false);
+  const [selectedProvider, setSelectedProvider] = useState('openai');
+  const [apiKeyMasked, setApiKeyMasked] = useState(true);
+  const [savedApiKey, setSavedApiKey] = useState('');
+  const [modelOptions, setModelOptions] = useState<{ value: string; label: 
string }[]>([]);
+  const [modelsLoading, setModelsLoading] = useState(false);
+
+  const initialized = useRef<boolean | null>(null);
+  if (initialized.current == null) {
+    initialized.current = true;
+    fetchConfig();
+    fetchModels();
+  }
+
+  function fetchConfig() {
+    setLoading(true);
+    getLlmConfig()
+      .then((config) => {
+        if (config) {
+          const provider = config.provider || 'openai';
+          setSelectedProvider(provider);
+          setEnabled(config.enabled || false);
+          if (config.apiKey) {
+            setSavedApiKey(config.apiKey);
+            form.setFieldsValue({ apiKey: maskApiKey(config.apiKey) });
+          }
+          form.setFieldsValue({
+            provider,
+            apiBase: config.apiBase || '',
+            model: config.model || '',
+            maxTokens: config.maxTokens || 4096,
+            temperature: config.temperature !== undefined ? config.temperature 
: 0.7,
+            deploymentName: config.deploymentName || '',
+            apiVersion: config.apiVersion || '2024-02-15-preview',
+            awsRegion: config.awsRegion || 'us-east-1',
+          });
+        }
+      })
+      .catch(() => {
+        message.error(t('llm.loadFailed'));
+      })
+      .finally(() => {
+        setLoading(false);
+      });
+  }
+
+  function fetchModels() {
+    setModelsLoading(true);
+    getLlmConfig()
+      .then((config) => {
+        if (!config || !config.enabled) {
+          setModelOptions([]);
+          return;
+        }
+        getLlmModels()
+          .then((result) => {
+            let models: string[] = [];
+            if (result && result.status === 0 && result.data) {
+              models = result.data.map((m) => m.id || m.name || 
'').filter(Boolean);
+            }
+            if (models.length === 0) {
+              const provider = config.provider || 'openai';
+              models = FALLBACK_MODELS[provider] || [config.model || 
''].filter(Boolean);
+            }
+            setModelOptions(models.map((m) => ({ value: m, label: m })));
+          })
+          .catch(() => {
+            const provider = config.provider || 'openai';
+            const fallback = FALLBACK_MODELS[provider] || [config.model || 
''].filter(Boolean);
+            setModelOptions(fallback.map((m) => ({ value: m, label: m })));
+          });
+      })
+      .catch(() => {
+        setModelOptions([]);
+      })
+      .finally(() => {
+        setModelsLoading(false);
+      });
+  }
+
+  const maskApiKey = (key: string) => {
+    if (!key || key.length < 8) return key ? '••••••••' : '';
+    return key.slice(0, 4) + '••••••••' + key.slice(-4);
+  };
+
+  const currentProvider = PROVIDERS.find((p) => p.key === selectedProvider) || 
PROVIDERS[0];
+
+  const handleProviderChange = useCallback(
+    (value: string) => {
+      setSelectedProvider(value);
+      setTestResult(null);
+      const provider = PROVIDERS.find((p) => p.key === value);
+      if (provider) {
+        form.setFieldsValue({
+          apiBase: provider.defaultBaseUrl,
+          model: provider.defaultModel,
+        });
+        if (!provider.requireApiKey) {
+          form.setFieldsValue({ apiKey: '' });
+          setSavedApiKey('');
+        }
+      }
+    },
+    [form],
+  );
+
+  const handleApiKeyFocus = () => {
+    if (apiKeyMasked && savedApiKey) {
+      form.setFieldsValue({ apiKey: '' });
+      setApiKeyMasked(false);
+    }
+  };
+
+  const handleApiKeyBlur = () => {
+    const val = form.getFieldValue('apiKey');
+    if (!val && savedApiKey) {
+      form.setFieldsValue({ apiKey: maskApiKey(savedApiKey) });
+      setApiKeyMasked(true);
+    }
+  };
+
+  const handleTestConnection = () => {
+    setTestLoading(true);
+    setTestResult(null);
+    form
+      .validateFields()
+      .then((values) => {
+        const testConfig: LlmConfig = {
+          ...values,
+          apiKey: apiKeyMasked && savedApiKey ? savedApiKey : values.apiKey || 
'',
+          enabled: true,
+        };
+        testLlmConnection(testConfig)
+          .then((result) => {
+            if (result && result.status === 0) {
+              setTestResult({ success: true, msg: result.msg || 
t('llm.testSuccessMsg') });
+              message.success(t('llm.testSuccess'));
+              // Auto-save after successful test
+              saveLlmConfig(testConfig)
+                .then(() => {
+                  if (testConfig.apiKey) {
+                    setSavedApiKey(testConfig.apiKey);
+                    form.setFieldsValue({ apiKey: 
maskApiKey(testConfig.apiKey) });
+                    setApiKeyMasked(true);
+                  }
+                })
+                .catch(() => {
+                  // auto-save failure is non-critical
+                });
+            } else {
+              setTestResult({
+                success: false,
+                msg: (result && result.errMsg) || t('llm.testFailedMsg'),
+              });
+              message.error(t('llm.testFailed'));
+            }
+          })
+          .catch((err) => {
+            setTestResult({
+              success: false,
+              msg: t('llm.testError') + (err.message || ''),
+            });
+            message.error(t('llm.testFailed'));
+          })
+          .finally(() => {
+            setTestLoading(false);
+          });
+      })
+      .catch(() => {
+        setTestLoading(false);
+      });
+  };
+
+  const handleSave = () => {
+    setLoading(true);
+    form
+      .validateFields()
+      .then((values) => {
+        const config: LlmConfig = {
+          ...values,
+          apiKey: apiKeyMasked && savedApiKey ? savedApiKey : values.apiKey || 
'',
+          enabled,
+        };
+        saveLlmConfig(config)
+          .then((result) => {
+            if (result && result.status === 0) {
+              message.success(t('llm.saveSuccess'));
+              if (config.apiKey) {
+                setSavedApiKey(config.apiKey);
+                form.setFieldsValue({ apiKey: maskApiKey(config.apiKey) });
+                setApiKeyMasked(true);
+              }
+            } else {
+              message.error((result && result.errMsg) || t('llm.saveFailed'));
+            }
+          })
+          .catch((err) => {
+            if (err.errorFields) {
+              message.error(t('llm.formIncomplete'));
+            } else {
+              message.error(t('llm.saveFailed'));
+            }
+          })
+          .finally(() => {
+            setLoading(false);
+          });
+      })
+      .catch(() => {
+        message.error(t('llm.formIncomplete'));
+        setLoading(false);
+      });
+  };
+
+  // ─── Provider Grid ──────────────────────────────────────────
+
+  const renderProviderGrid = () => (
+    <div
+      style={{
+        display: 'grid',
+        gridTemplateColumns: 'repeat(3, 1fr)',
+        gap: 12,
+        marginBottom: 4,
+      }}
+    >
+      {PROVIDERS.map((p) => (
+        <div
+          key={p.key}
+          onClick={() => {
+            form.setFieldsValue({ provider: p.key });
+            handleProviderChange(p.key);
+          }}
+          style={{
+            position: 'relative',
+            display: 'flex',
+            alignItems: 'center',
+            gap: 12,
+            padding: '14px 16px',
+            border: `2px solid ${selectedProvider === p.key ? p.color : 
'#f0f0f0'}`,
+            borderRadius: 10,
+            cursor: 'pointer',
+            background: selectedProvider === p.key ? '#fafbff' : '#ffffff',
+            boxShadow: selectedProvider === p.key ? '0 2px 8px rgba(22, 119, 
255, 0.1)' : 'none',
+            transition: 'all 0.2s ease',
+            userSelect: 'none',
+          }}
+        >
+          <div
+            style={{
+              flexShrink: 0,
+              width: 40,
+              height: 40,
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              borderRadius: 8,
+              fontSize: 20,
+              fontWeight: 600,
+              background: p.color + '15',
+              color: p.color,
+            }}
+          >
+            {p.icon.length <= 2 ? p.icon : <Cloud size={20} />}
+          </div>
+          <div style={{ flex: 1, minWidth: 0 }}>
+            <div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.4 
}}>{p.label}</div>
+            <div
+              style={{
+                fontSize: 11,
+                color: 'rgba(0,0,0,0.45)',
+                lineHeight: 1.4,
+                whiteSpace: 'nowrap',
+                overflow: 'hidden',
+                textOverflow: 'ellipsis',
+              }}
+            >
+              {t(p.descKey)}
+            </div>
+          </div>
+          {selectedProvider === p.key && (
+            <CheckCircle
+              size={16}
+              weight="fill"
+              style={{ position: 'absolute', top: 8, right: 8, color: p.color 
}}
+            />
+          )}
+        </div>
+      ))}
+    </div>
+  );
+
+  // ─── Render ──────────────────────────────────────────────────
+
+  return (
+    <div style={{ maxWidth: 960, margin: '0 auto', padding: '0 24px 40px' }}>
+      <PageHeader
+        title={t('llm.title')}
+
+        extra={
+          <Space>
+            <Text type="secondary" style={{ fontSize: 13, marginRight: 8 }}>
+              {t('llm.enable')}
+            </Text>
+            <Switch
+              checked={enabled}
+              onChange={setEnabled}
+              checkedChildren={t('llm.on')}
+              unCheckedChildren={t('llm.off')}
+            />
+          </Space>
+        }
+      />
+
+      <Card
+        loading={loading}
+        style={{ borderRadius: 12, boxShadow: '0 1px 4px rgba(0,0,0,0.06)' }}
+        styles={{ body: { padding: '24px 28px' } }}
+      >
+        <Form
+          form={form}
+          layout="vertical"
+          initialValues={{
+            provider: 'openai',
+            model: 'gpt-4o',
+            apiBase: 'https://api.openai.com/v1',
+            maxTokens: 4096,
+            temperature: 0.7,
+            apiVersion: '2024-02-15-preview',
+            awsRegion: 'us-east-1',
+          }}
+        >
+          {/* Provider Selection */}
+          <div style={{ fontSize: 15, fontWeight: 600, marginBottom: 16 }}>
+            {t('llm.selectProvider')}
+          </div>
+          {renderProviderGrid()}
+          <Form.Item name="provider" hidden>
+            <Input />
+          </Form.Item>
+
+          <Divider style={{ margin: '20px 0 16px' }} />
+
+          {/* Connection Config */}
+          <div
+            style={{
+              fontSize: 15,
+              fontWeight: 600,
+              marginBottom: 16,
+              display: 'flex',
+              alignItems: 'center',
+            }}
+          >
+            <Globe size={16} style={{ marginRight: 6 }} />
+            {t('llm.connectionConfig')}
+          </div>
+
+          <Row gutter={16}>
+            <Col span={12}>
+              <Form.Item
+                name="apiKey"
+                label={
+                  <span>
+                    <Key size={14} style={{ marginRight: 4, verticalAlign: 
'middle' }} />
+                    {t('llm.apiKey')}
+                    {currentProvider.requireApiKey && <Text type="danger"> 
*</Text>}
+                  </span>
+                }
+                rules={
+                  currentProvider.requireApiKey
+                    ? [{ required: true, message: t('llm.apiKeyRequired') }]
+                    : []
+                }
+                extra={t('llm.apiKeyEncrypted')}
+              >
+                <Input.Password
+                  placeholder={
+                    currentProvider.requireApiKey
+                      ? t('llm.apiKeyPlaceholder')
+                      : t('llm.apiKeyNoRequired')
+                  }
+                  onFocus={handleApiKeyFocus}
+                  onBlur={handleApiKeyBlur}
+                  visibilityToggle
+                />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                name="apiBase"
+                label={
+                  <span>
+                    <Globe size={14} style={{ marginRight: 4, verticalAlign: 
'middle' }} />
+                    {t('llm.apiBase')}
+                    {currentProvider.requireBaseUrl && <Text type="danger"> 
*</Text>}
+                  </span>
+                }
+                rules={
+                  currentProvider.requireBaseUrl
+                    ? [{ required: true, message: t('llm.apiBaseRequired') }]
+                    : []
+                }
+                extra={
+                  currentProvider.requireBaseUrl
+                    ? t('llm.apiBaseRequiredHint')
+                    : t('llm.apiBaseCustom')
+                }
+              >
+                <Input
+                  placeholder={currentProvider.defaultBaseUrl || 
'https://api.openai.com/v1'}
+                />
+              </Form.Item>
+            </Col>
+          </Row>
+
+          {/* Azure extra fields */}
+          {selectedProvider === 'azure' && (
+            <Row gutter={16}>
+              <Col span={12}>
+                <Form.Item
+                  name="deploymentName"
+                  label={t('llm.deploymentName')}
+                  rules={[{ required: true, message: 
t('llm.deploymentNameRequired') }]}
+                >
+                  <Input placeholder="my-gpt4-deployment" />
+                </Form.Item>
+              </Col>
+              <Col span={12}>
+                <Form.Item
+                  name="apiVersion"
+                  label={t('llm.apiVersion')}
+                  rules={[{ required: true, message: 
t('llm.apiVersionRequired') }]}
+                >
+                  <Select placeholder={t('llm.apiVersion')}>
+                    <Select.Option 
value="2024-02-15-preview">2024-02-15-preview</Select.Option>
+                    <Select.Option 
value="2024-08-01-preview">2024-08-01-preview</Select.Option>
+                    <Select.Option 
value="2025-01-01-preview">2025-01-01-preview</Select.Option>
+                  </Select>
+                </Form.Item>
+              </Col>
+            </Row>
+          )}
+
+          {/* AWS Bedrock extra fields */}
+          {selectedProvider === 'bedrock' && (
+            <Form.Item
+              name="awsRegion"
+              label={t('llm.awsRegion')}
+              rules={[{ required: true, message: t('llm.awsRegionRequired') }]}
+            >
+              <Select placeholder={t('llm.awsRegion')}>
+                <Select.Option value="us-east-1">us-east-1</Select.Option>
+                <Select.Option value="us-west-2">us-west-2</Select.Option>
+                <Select.Option value="eu-west-1">eu-west-1</Select.Option>
+                <Select.Option 
value="ap-northeast-1">ap-northeast-1</Select.Option>
+                <Select.Option 
value="ap-southeast-1">ap-southeast-1</Select.Option>
+              </Select>
+            </Form.Item>
+          )}
+
+          <Divider style={{ margin: '20px 0 16px' }} />
+
+          {/* Model Parameters */}
+          <div
+            style={{
+              fontSize: 15,
+              fontWeight: 600,
+              marginBottom: 16,
+              display: 'flex',
+              alignItems: 'center',
+            }}
+          >
+            <Lightning size={16} style={{ marginRight: 6 }} />
+            {t('llm.modelParams')}
+          </div>
+
+          <Form.Item
+            name="model"
+            label={t('llm.model')}
+            rules={[{ required: true, message: t('llm.modelRequired') }]}
+            extra={t('llm.modelExtra')}
+          >
+            <Select
+              showSearch
+              loading={modelsLoading}
+              placeholder={modelsLoading ? t('llm.modelsLoading') : 
currentProvider.defaultModel}
+              filterOption={(input, option) =>
+                (option?.label ?? 
'').toLowerCase().includes(input.toLowerCase())
+              }
+              notFoundContent={modelsLoading ? t('common.loading') : 
t('llm.modelsNotFound')}
+              options={modelOptions}
+            />
+          </Form.Item>
+
+          <Row gutter={16}>
+            <Col span={12}>
+              <Form.Item noStyle shouldUpdate={(prev, cur) => prev.maxTokens 
!== cur.maxTokens}>
+                {({ getFieldValue }) => (
+                  <Form.Item
+                    name="maxTokens"
+                    label={
+                      <span>
+                        {t('llm.maxTokens')}
+                        <Text type="secondary" style={{ marginLeft: 8, 
fontSize: 12 }}>
+                          {getFieldValue('maxTokens') ?? 4096}
+                        </Text>
+                      </span>
+                    }
+                  >
+                    <Slider
+                      min={256}
+                      max={128000}
+                      step={256}
+                      marks={{
+                        2048: { label: '2K', style: { fontSize: 11 } },
+                        8192: { label: '8K', style: { fontSize: 11 } },
+                        32768: { label: '32K', style: { fontSize: 11 } },
+                        128000: { label: '128K', style: { fontSize: 11 } },
+                      }}
+                    />
+                  </Form.Item>
+                )}
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item noStyle shouldUpdate={(prev, cur) => prev.temperature 
!== cur.temperature}>
+                {({ getFieldValue }) => (
+                  <Form.Item
+                    name="temperature"
+                    label={
+                      <span>
+                        {t('llm.temperature')}
+                        <Text type="secondary" style={{ marginLeft: 8, 
fontSize: 12 }}>
+                          {getFieldValue('temperature') ?? 0.7}
+                        </Text>
+                      </span>
+                    }
+                    extra={t('llm.temperatureExtra')}
+                  >
+                    <Slider
+                      min={0}
+                      max={2}
+                      step={0.1}
+                      marks={{
+                        0: { label: '0', style: { fontSize: 11 } },
+                        0.7: { label: '0.7', style: { fontSize: 11 } },
+                        1: { label: '1', style: { fontSize: 11 } },
+                        2: { label: '2', style: { fontSize: 11 } },
+                      }}
+                    />
+                  </Form.Item>
+                )}
+              </Form.Item>
+            </Col>
+          </Row>
+
+          <Divider style={{ margin: '20px 0 16px' }} />
+
+          {/* Actions */}
+          <div
+            style={{
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+              flexWrap: 'wrap',
+              gap: 12,
+            }}
+          >
+            <Space size="middle">
+              <Button
+                type="primary"
+                icon={<FloppyDisk size={14} />}
+                onClick={handleSave}
+                loading={loading}
+                size="large"
+              >
+                {t('llm.saveConfig')}
+              </Button>
+              <Button
+                icon={<Lightning size={14} />}
+                onClick={handleTestConnection}
+                loading={testLoading}
+                size="large"
+              >
+                {testLoading ? t('llm.testing') : t('llm.testConnection')}
+              </Button>
+            </Space>
+            <div style={{ display: 'flex', alignItems: 'center' }}>
+              <ShieldCheck size={14} style={{ marginRight: 4 }} />
+              <Text type="secondary" style={{ fontSize: 12 }}>
+                {t('llm.securityNote')}
+              </Text>
+            </div>
+          </div>
+
+          {/* Test Result */}
+          {testResult && (
+            <Alert
+              style={{ marginTop: 16, borderRadius: 8 }}
+              type={testResult.success ? 'success' : 'error'}
+              showIcon
+              icon={
+                testResult.success ? (
+                  <CheckCircle size={16} weight="fill" />
+                ) : (
+                  <XCircle size={16} weight="fill" />
+                )
+              }
+              message={testResult.msg}
+              closable
+              onClose={() => setTestResult(null)}
+            />
+          )}
+        </Form>
+      </Card>
+    </div>
+  );
+};
+
+export default LlmSettingsPage;

Reply via email to