This is an automated email from the ASF dual-hosted git repository. gongchao pushed a commit to branch ai-enhance in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 73481b7d3ed480b5623ded80298f99e718725f67 Author: tomsun28 <[email protected]> AuthorDate: Wed Oct 22 21:59:17 2025 +0800 [chore] refactor ai service Signed-off-by: tomsun28 <[email protected]> --- .../hertzbeat/ai/agent/config/LlmConfig.java | 132 +++++++++++++++++---- .../impl/ChatClientProviderServiceImpl.java | 15 ++- web-app/src/app/pojo/ModelProviderConfig.ts | 34 +++++- .../shared/components/ai-chat/ai-chat.module.ts | 2 + .../shared/components/ai-chat/chat.component.html | 58 ++++++++- .../shared/components/ai-chat/chat.component.less | 35 ++++++ .../shared/components/ai-chat/chat.component.ts | 72 ++++++++++- 7 files changed, 314 insertions(+), 34 deletions(-) diff --git a/hertzbeat-ai-agent/src/main/java/org/apache/hertzbeat/ai/agent/config/LlmConfig.java b/hertzbeat-ai-agent/src/main/java/org/apache/hertzbeat/ai/agent/config/LlmConfig.java index ebe38342a..ff3b2742a 100644 --- a/hertzbeat-ai-agent/src/main/java/org/apache/hertzbeat/ai/agent/config/LlmConfig.java +++ b/hertzbeat-ai-agent/src/main/java/org/apache/hertzbeat/ai/agent/config/LlmConfig.java @@ -18,55 +18,145 @@ package org.apache.hertzbeat.ai.agent.config; +import lombok.extern.slf4j.Slf4j; +import org.apache.hertzbeat.ai.agent.event.AiProviderConfigChangeEvent; +import org.apache.hertzbeat.ai.agent.pojo.dto.ModelProviderConfig; +import org.apache.hertzbeat.base.dao.GeneralConfigDao; +import org.apache.hertzbeat.common.entity.manager.GeneralConfig; +import org.apache.hertzbeat.common.util.JsonUtil; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; /** * Configuration class for Large Language Model (LLM) settings. */ @Configuration +@Slf4j public class LlmConfig { - /** - * Create OpenAI API instance with dynamic API key - */ - @Bean - public OpenAiApi openAiApi(DynamicOpenAiApiKey dynamicApiKey) { - return OpenAiApi.builder() - .apiKey(dynamicApiKey) - .build(); + private final GeneralConfigDao generalConfigDao; + + private ApplicationContext applicationContext; + + public LlmConfig(GeneralConfigDao generalConfigDao, ApplicationContext applicationContext) { + this.generalConfigDao = generalConfigDao; + this.applicationContext = applicationContext; } /** - * Create OpenAI Chat Options with custom settings + * Create ChatClient bean with all dependencies created internally */ @Bean - public OpenAiChatOptions openAiChatOptions() { - return OpenAiChatOptions.builder() - .model("model") - .temperature(0.3) - .build(); + public ChatClient openAiChatClient() { + return createChatClient(); } /** - * Create OpenAI Chat Model with custom API configuration + * Create ChatClient with all necessary components */ - @Bean - public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions openAiChatOptions) { - return OpenAiChatModel.builder() + private ChatClient createChatClient() { + + GeneralConfig providerConfig = generalConfigDao.findByType("provider"); + if (providerConfig == null || providerConfig.getContent() == null) { + log.warn("LLM Provider is not set, ChatClient bean will not be created"); + return null; + } + ModelProviderConfig modelProviderConfig = JsonUtil.fromJson(providerConfig.getContent(), ModelProviderConfig.class); + + if (!modelProviderConfig.isEnable() || !modelProviderConfig.isStatus()) { + log.warn("LLM Provider is not enabled or status is not valid, ChatClient bean will not be created"); + return null; + } + + if (modelProviderConfig.getApiKey() == null) { + log.warn("LLM Provider configuration is incomplete, ChatClient bean will not be created"); + return null; + } + + if (modelProviderConfig.getBaseUrl() == null) { + if ("openai".equals(modelProviderConfig.getCode())) { + modelProviderConfig.setBaseUrl("https://api.openai.com/v1"); + } else if ("zhipu".equals(modelProviderConfig.getCode())) { + modelProviderConfig.setBaseUrl("https://open.bigmodel.cn/api/paas/v4"); + } else if ("zai".equals(modelProviderConfig.getCode())) { + modelProviderConfig.setBaseUrl("https://api.z.ai/api/paas/v4"); + } else { + modelProviderConfig.setBaseUrl("https://api.openai.com/v1"); + } + } + + if (modelProviderConfig.getModel() == null) { + if ("openai".equals(modelProviderConfig.getCode())) { + modelProviderConfig.setModel("gpt-5"); + } else if ("zhipu".equals(modelProviderConfig.getCode())) { + modelProviderConfig.setModel("glm-4.6"); + } else if ("zai".equals(modelProviderConfig.getCode())) { + modelProviderConfig.setModel("glm-4.6"); + } else { + modelProviderConfig.setModel("gpt-5"); + } + } + + // Create OpenAI API instance + OpenAiApi openAiApi = OpenAiApi.builder() + .baseUrl(modelProviderConfig.getBaseUrl()) + .apiKey(modelProviderConfig.getApiKey()) + .build(); + + // Create OpenAI Chat Options + OpenAiChatOptions openAiChatOptions = OpenAiChatOptions.builder() + .model(modelProviderConfig.getModel()) + .temperature(0.3) + .build(); + + // Create OpenAI Chat Model + OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() .openAiApi(openAiApi) .defaultOptions(openAiChatOptions) .build(); + + // Create and return ChatClient + return ChatClient.create(openAiChatModel); } - @Bean - public ChatClient openAiChatClient(OpenAiChatModel openAiChatModel) { - return ChatClient.create(openAiChatModel); + /** + * AI configuration change event listener + * Uses ApplicationContext to unregister and re-register the ChatClient bean + */ + @EventListener(AiProviderConfigChangeEvent.class) + public void onAiProviderConfigChange(AiProviderConfigChangeEvent event) { + log.info("Provider configuration change event received, refreshing ChatClient bean"); + + try { + ConfigurableApplicationContext configurableContext = (ConfigurableApplicationContext) applicationContext; + DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) configurableContext.getBeanFactory(); + + // Remove the existing ChatClient bean + if (beanFactory.containsSingleton("openAiChatClient")) { + beanFactory.destroySingleton("openAiChatClient"); + log.info("Existing ChatClient bean destroyed"); + } + + // Create new ChatClient with updated configuration + ChatClient newChatClient = createChatClient(); + + // Register the new ChatClient bean + beanFactory.registerSingleton("openAiChatClient", newChatClient); + + log.info("ChatClient bean refreshed successfully with new AI provider configuration"); + + } catch (Exception e) { + log.error("Failed to refresh ChatClient bean after configuration change", e); + } } } diff --git a/hertzbeat-ai-agent/src/main/java/org/apache/hertzbeat/ai/agent/service/impl/ChatClientProviderServiceImpl.java b/hertzbeat-ai-agent/src/main/java/org/apache/hertzbeat/ai/agent/service/impl/ChatClientProviderServiceImpl.java index 51d73b5b9..933334df9 100644 --- a/hertzbeat-ai-agent/src/main/java/org/apache/hertzbeat/ai/agent/service/impl/ChatClientProviderServiceImpl.java +++ b/hertzbeat-ai-agent/src/main/java/org/apache/hertzbeat/ai/agent/service/impl/ChatClientProviderServiceImpl.java @@ -31,6 +31,7 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationContext; import reactor.core.publisher.Flux; import java.util.ArrayList; @@ -45,19 +46,20 @@ import java.util.List; @Service public class ChatClientProviderServiceImpl implements ChatClientProviderService { - private final ChatClient chatClient; + private final ApplicationContext applicationContext; @Qualifier("hertzbeatTools") @Autowired private ToolCallbackProvider toolCallbackProvider; @Autowired - public ChatClientProviderServiceImpl(ChatClient openAiChatClient) { - this.chatClient = openAiChatClient; + public ChatClientProviderServiceImpl(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; } public String complete(String message) { - return this.chatClient.prompt() + ChatClient chatClient = applicationContext.getBean("openAiChatClient", ChatClient.class); + return chatClient.prompt() .user(message) .call() .content(); @@ -66,6 +68,9 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService @Override public Flux<String> streamChat(ChatRequestContext context) { try { + // Get the current (potentially refreshed) ChatClient instance + ChatClient chatClient = applicationContext.getBean("openAiChatClient", ChatClient.class); + List<Message> messages = new ArrayList<>(); // Add conversation history if available @@ -83,7 +88,7 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService log.info("Starting streaming chat for conversation: {}", context.getConversationId()); - return this.chatClient.prompt() + return chatClient.prompt() .messages(messages) .system(PromptProvider.HERTZBEAT_SYSTEM_PROMPT) .toolCallbacks(toolCallbackProvider) diff --git a/web-app/src/app/pojo/ModelProviderConfig.ts b/web-app/src/app/pojo/ModelProviderConfig.ts index 717409609..93879d934 100644 --- a/web-app/src/app/pojo/ModelProviderConfig.ts +++ b/web-app/src/app/pojo/ModelProviderConfig.ts @@ -22,8 +22,36 @@ export class ModelProviderConfig { status: boolean = false; error!: string; type!: string; - code!: string; - baseUrl!: string; - model!: string; + code: string = 'openai'; + baseUrl: string = ''; + model: string = ''; apiKey!: string; } + +export interface ProviderOption { + value: string; + label: string; + defaultBaseUrl: string; + defaultModel: string; +} + +export const PROVIDER_OPTIONS: ProviderOption[] = [ + { + value: 'openai', + label: 'OpenAI', + defaultBaseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4' + }, + { + value: 'zhipu', + label: 'ZhiPu (智谱)', + defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', + defaultModel: 'glm-4' + }, + { + value: 'zai', + label: 'ZAI', + defaultBaseUrl: 'https://api.z.ai/api/paas/v4', + defaultModel: 'glm-4' + } +]; diff --git a/web-app/src/app/shared/components/ai-chat/ai-chat.module.ts b/web-app/src/app/shared/components/ai-chat/ai-chat.module.ts index 0f3ac1a0e..ca16c108b 100644 --- a/web-app/src/app/shared/components/ai-chat/ai-chat.module.ts +++ b/web-app/src/app/shared/components/ai-chat/ai-chat.module.ts @@ -28,6 +28,7 @@ import { NzIconModule } from 'ng-zorro-antd/icon'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzMessageModule } from 'ng-zorro-antd/message'; import { NzModalModule } from 'ng-zorro-antd/modal'; +import { NzSelectModule } from 'ng-zorro-antd/select'; import { NzSpinModule } from 'ng-zorro-antd/spin'; import { MarkdownPipe } from 'ngx-markdown'; @@ -47,6 +48,7 @@ import { ChatComponent } from './chat.component'; NzInputModule, NzMessageModule, NzModalModule, + NzSelectModule, NzSpinModule, MarkdownPipe ], diff --git a/web-app/src/app/shared/components/ai-chat/chat.component.html b/web-app/src/app/shared/components/ai-chat/chat.component.html index f55bd9387..1ae68dff2 100644 --- a/web-app/src/app/shared/components/ai-chat/chat.component.html +++ b/web-app/src/app/shared/components/ai-chat/chat.component.html @@ -160,18 +160,40 @@ <!-- OpenAI Configuration Modal --> <nz-modal [(nzVisible)]="showConfigModal" - nzTitle="Ai Provider Configuration" + nzTitle="AI Provider Configuration" (nzOnCancel)="onCloseConfigModal()" (nzOnOk)="onSaveAiProviderConfig()" nzMaskClosable="false" [nzClosable]="false" - nzWidth="600px" + nzWidth="700px" [nzOkLoading]="configLoading" nzOkText="Validate & Save" nzCancelText="Cancel" > <div *nzModalContent class="-inner-content"> <form nz-form nzLayout="vertical"> + <!-- Provider Selection --> + <nz-form-item> + <nz-form-label nzRequired="true">AI Provider</nz-form-label> + <nz-form-control nzErrorTip="Please select an AI provider"> + <nz-select + [(ngModel)]="aiProviderConfig.code" + name="provider" + nzPlaceHolder="Select AI Provider" + (ngModelChange)="onProviderChange($event)" + style="width: 100%" + > + <nz-option + *ngFor="let option of providerOptions" + [nzValue]="option.value" + [nzLabel]="option.label" + ></nz-option> + </nz-select> + <p class="form-help">Choose your AI provider (OpenAI, ZhiPu, or ZAI)</p> + </nz-form-control> + </nz-form-item> + + <!-- API Key --> <nz-form-item> <nz-form-label nzRequired="true">API Key</nz-form-label> <nz-form-control nzErrorTip="API Key is required"> @@ -179,6 +201,38 @@ <p class="form-help">Your Provider API key. The key will be validated when saved.</p> </nz-form-control> </nz-form-item> + + <!-- Base URL --> + <nz-form-item> + <nz-form-label>Base URL</nz-form-label> + <nz-form-control> + <nz-input-group nzSuffix="resetBtn"> + <input nz-input [(ngModel)]="aiProviderConfig.baseUrl" name="baseUrl" placeholder="https://api.openai.com/v1" /> + <ng-template #resetBtn> + <button + nz-button + nzType="link" + nzSize="small" + (click)="resetToDefaults()" + nzTooltipTitle="Reset to default values" + nz-tooltip + > + <i nz-icon nzType="reload"></i> + </button> + </ng-template> + </nz-input-group> + <p class="form-help">Custom API endpoint URL. Leave empty to use default for selected provider.</p> + </nz-form-control> + </nz-form-item> + + <!-- Model --> + <nz-form-item> + <nz-form-label>Model</nz-form-label> + <nz-form-control> + <input nz-input [(ngModel)]="aiProviderConfig.model" name="model" placeholder="gpt-4" /> + <p class="form-help">Model name to use. Leave empty to use default for selected provider.</p> + </nz-form-control> + </nz-form-item> </form> </div> </nz-modal> diff --git a/web-app/src/app/shared/components/ai-chat/chat.component.less b/web-app/src/app/shared/components/ai-chat/chat.component.less index 91e6c0005..8edbb3a1c 100644 --- a/web-app/src/app/shared/components/ai-chat/chat.component.less +++ b/web-app/src/app/shared/components/ai-chat/chat.component.less @@ -634,6 +634,41 @@ body[data-theme='dark'] .chat-container { } } +// Provider configuration form styles +.provider-config-form { + .provider-select-row { + display: flex; + align-items: center; + gap: 8px; + + nz-select { + flex: 1; + } + } + + .baseurl-input-row { + display: flex; + align-items: center; + gap: 8px; + + nz-input-group { + flex: 1; + } + + .reset-btn { + flex-shrink: 0; + } + } + + .form-item-with-help { + margin-bottom: 8px; + + + .form-help { + margin-bottom: 16px; + } + } +} + nz-modal { .ant-modal-body { padding: 24px; diff --git a/web-app/src/app/shared/components/ai-chat/chat.component.ts b/web-app/src/app/shared/components/ai-chat/chat.component.ts index c7f8f1436..1c4ed5eac 100644 --- a/web-app/src/app/shared/components/ai-chat/chat.component.ts +++ b/web-app/src/app/shared/components/ai-chat/chat.component.ts @@ -22,7 +22,7 @@ import { I18NService } from '@core'; import { NzMessageService } from 'ng-zorro-antd/message'; import { NzModalService } from 'ng-zorro-antd/modal'; -import { ModelProviderConfig } from '../../../pojo/ModelProviderConfig'; +import { ModelProviderConfig, PROVIDER_OPTIONS, ProviderOption } from '../../../pojo/ModelProviderConfig'; import { AiChatService, ChatMessage, ConversationDto } from '../../../service/ai-chat.service'; import { GeneralConfigService } from '../../../service/general-config.service'; import { ThemeService } from '../../../service/theme.service'; @@ -48,6 +48,7 @@ export class ChatComponent implements OnInit, AfterViewChecked { showConfigModal = false; configLoading = false; aiProviderConfig: ModelProviderConfig = new ModelProviderConfig(); + providerOptions: ProviderOption[] = PROVIDER_OPTIONS; constructor( private aiChatService: AiChatService, @@ -433,15 +434,37 @@ export class ChatComponent implements OnInit, AfterViewChecked { next: response => { if (response.code === 0 && response.data) { this.aiProviderConfig = response.data; + // Ensure default values are set if not present + if (!this.aiProviderConfig.code) { + this.aiProviderConfig.code = 'openai'; + } + if (!this.aiProviderConfig.baseUrl) { + const defaultProvider = this.providerOptions.find(p => p.value === this.aiProviderConfig.code); + if (defaultProvider) { + this.aiProviderConfig.baseUrl = defaultProvider.defaultBaseUrl; + } + } + if (!this.aiProviderConfig.model) { + const defaultProvider = this.providerOptions.find(p => p.value === this.aiProviderConfig.code); + if (defaultProvider) { + this.aiProviderConfig.model = defaultProvider.defaultModel; + } + } + if (!response.data.enable) { this.loadConversations(); } else { this.showAiProviderConfigDialog(response.data.error); } + } else { + // Initialize with default values if no config exists + this.aiProviderConfig = new ModelProviderConfig(); + this.showAiProviderConfigDialog(); } }, error: error => { console.error('Failed to load model provider config:', error); + this.aiProviderConfig = new ModelProviderConfig(); this.showAiProviderConfigDialog(); } }); @@ -502,8 +525,23 @@ export class ChatComponent implements OnInit, AfterViewChecked { * Save OpenAI configuration */ onSaveAiProviderConfig(): void { - if (!this.aiProviderConfig.apiKey.trim()) { - this.message.error('API Key is required'); + if (!this.aiProviderConfig.apiKey?.trim()) { + this.message.error('Please enter API Key'); + return; + } + + if (!this.aiProviderConfig.code?.trim()) { + this.message.error('Please select a provider'); + return; + } + + if (!this.aiProviderConfig.baseUrl?.trim()) { + this.message.error('Please enter Base URL'); + return; + } + + if (!this.aiProviderConfig.model?.trim()) { + this.message.error('Please enter Model'); return; } @@ -536,4 +574,32 @@ export class ChatComponent implements OnInit, AfterViewChecked { } }); } + + /** + * Handle provider selection change + */ + onProviderChange(provider: string): void { + const selectedProvider = this.providerOptions.find(p => p.value === provider); + if (selectedProvider) { + this.aiProviderConfig.code = provider; + // Auto-fill default values if current values are empty + if (!this.aiProviderConfig.baseUrl) { + this.aiProviderConfig.baseUrl = selectedProvider.defaultBaseUrl; + } + if (!this.aiProviderConfig.model) { + this.aiProviderConfig.model = selectedProvider.defaultModel; + } + } + } + + /** + * Reset to default values for selected provider + */ + resetToDefaults(): void { + const selectedProvider = this.providerOptions.find(p => p.value === this.aiProviderConfig.code); + if (selectedProvider) { + this.aiProviderConfig.baseUrl = selectedProvider.defaultBaseUrl; + this.aiProviderConfig.model = selectedProvider.defaultModel; + } + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
