diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index 595dfc9..e23e468 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -5,6 +5,7 @@ const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8 const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8'); const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8'); const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8'); +const runtimeResourcesSource = readFileSync(new URL('./ai/useAIChatRuntimeResources.ts', import.meta.url), 'utf8'); const systemContextSource = readFileSync(new URL('./ai/aiSystemContextMessages.ts', import.meta.url), 'utf8'); const runtimeSource = readFileSync(new URL('../utils/aiChatRuntime.ts', import.meta.url), 'utf8'); @@ -21,15 +22,17 @@ describe('AIChatPanel message render isolation', () => { }); it('loads user prompt settings and appends them as system messages', () => { - expect(source).toContain('AIGetUserPromptSettings'); - expect(source).toContain("window.addEventListener('gonavi:ai:config-changed'"); + expect(source).toContain("import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources';"); + expect(source).toContain('useAIChatRuntimeResources({ onOpenSettings })'); + expect(runtimeResourcesSource).toContain('AIGetUserPromptSettings'); + expect(runtimeResourcesSource).toContain("window.addEventListener('gonavi:ai:config-changed'"); expect(systemContextSource).toContain('以下是当前用户的自定义补充提示词'); expect(systemContextSource).toContain("appendCustomPromptGroup(systemMessages, ['database']"); }); it('loads MCP tools and skills into the runtime tool chain', () => { - expect(source).toContain('AIListMCPTools'); - expect(source).toContain('AIGetSkills'); + expect(runtimeResourcesSource).toContain('AIListMCPTools'); + expect(runtimeResourcesSource).toContain('AIGetSkills'); expect(source).toContain('executeLocalAIToolCall'); expect(systemContextSource).toContain('以下是当前启用的 Skill'); expect(source).toContain('buildAvailableAIChatTools'); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index d4c1dbd..5e3ac7d 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -5,10 +5,6 @@ import { EventsOn, EventsOff } from '../../wailsjs/runtime'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import type { AIChatMessage, - AIMCPToolDescriptor, - AIProviderConfig, - AISkillConfig, - AIUserPromptSettings, AIToolCall, JVMAIPlanContext, JVMDiagnosticPlanContext, @@ -19,13 +15,11 @@ import { AIChatHeader } from './ai/AIChatHeader'; import { AIChatInput } from './ai/AIChatInput'; import { AIHistoryDrawer } from './ai/AIHistoryDrawer'; import AIChatPanelConversationView from './ai/AIChatPanelConversationView'; -import type { AIComposerNotice, AIComposerNoticeAction } from '../utils/aiComposerNotice'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { buildIncompleteProviderNotice, buildMissingModelNotice, buildMissingProviderNotice, - buildModelFetchFailedNotice, } from '../utils/aiComposerNotice'; import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut'; import { toAIRequestMessage } from '../utils/aiMessagePayload'; @@ -48,6 +42,7 @@ import { } from './ai/aiChatPanelDerivedState'; import { buildAIChatReadinessSnapshot } from './ai/aiChatReadiness'; import { buildAISystemContextMessages } from './ai/aiSystemContextMessages'; +import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources'; interface AIChatPanelProps { width?: number; @@ -61,31 +56,31 @@ interface AIChatPanelProps { const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; -const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = { - global: '', - database: '', - jvm: '', - jvmDiagnostic: '', -}; - export const AIChatPanel: React.FC = ({ width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme }) => { const [input, setInput] = useState(''); const [draftImages, setDraftImages] = useState([]); const [sending, setSending] = useState(false); - const [activeProvider, setActiveProvider] = useState(null); - const [userPromptSettings, setUserPromptSettings] = useState(EMPTY_AI_USER_PROMPT_SETTINGS); - const [mcpTools, setMcpTools] = useState([]); - const [skills, setSkills] = useState([]); - const [dynamicModels, setDynamicModels] = useState([]); const [showScrollBottom, setShowScrollBottom] = useState(false); - const [loadingModels, setLoadingModels] = useState(false); - const [composerNotice, setComposerNotice] = useState(null); const [panelWidth, setPanelWidth] = useState(width); const [isResizing, setIsResizing] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat'); + const { + activeProvider, + composerNotice, + dynamicModels, + fetchDynamicModels, + handleComposerAction, + handleModelChange, + handleOpenSettingsFromPanel, + loadingModels, + mcpTools, + setComposerNotice, + skills, + userPromptSettings, + } = useAIChatRuntimeResources({ onOpenSettings }); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -260,180 +255,6 @@ export const AIChatPanel: React.FC = ({ const quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)'; const quickActionBorder = overlayTheme.sectionBorder; - const loadActiveProvider = useCallback(async () => { - try { - const Service = (window as any).go?.aiservice?.Service; - if (!Service) return; - const [provRes, activeRes] = await Promise.all([ - Service.AIGetProviders?.(), - Service.AIGetActiveProvider?.(), - ]); - if (Array.isArray(provRes) && activeRes) { - const current = provRes.find((p: any) => p.id === activeRes); - setActiveProvider(current || null); - } - } catch (e) { console.warn('Failed to load active provider', e); } - }, []); - - useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]); - - const loadUserPromptSettings = useCallback(async () => { - try { - const Service = (window as any).go?.aiservice?.Service; - if (!Service?.AIGetUserPromptSettings) { - setUserPromptSettings(EMPTY_AI_USER_PROMPT_SETTINGS); - return; - } - const nextSettings = await Service.AIGetUserPromptSettings(); - setUserPromptSettings({ - ...EMPTY_AI_USER_PROMPT_SETTINGS, - ...nextSettings, - }); - } catch (e) { - console.warn('Failed to load user prompt settings', e); - } - }, []); - - const loadMCPTools = useCallback(async () => { - try { - const Service = (window as any).go?.aiservice?.Service; - if (!Service?.AIListMCPTools) { - setMcpTools([]); - return; - } - const nextTools = await Service.AIListMCPTools(); - setMcpTools(Array.isArray(nextTools) ? nextTools : []); - } catch (e) { - console.warn('Failed to load MCP tools', e); - setMcpTools([]); - } - }, []); - - const loadSkills = useCallback(async () => { - try { - const Service = (window as any).go?.aiservice?.Service; - if (!Service?.AIGetSkills) { - setSkills([]); - return; - } - const nextSkills = await Service.AIGetSkills(); - setSkills(Array.isArray(nextSkills) ? nextSkills : []); - } catch (e) { - console.warn('Failed to load skills', e); - setSkills([]); - } - }, []); - - useEffect(() => { - void loadUserPromptSettings(); - void loadMCPTools(); - void loadSkills(); - const handleAIConfigChanged = () => { - void loadUserPromptSettings(); - void loadMCPTools(); - void loadSkills(); - void loadActiveProvider(); - }; - window.addEventListener('gonavi:ai:config-changed', handleAIConfigChanged as EventListener); - return () => { - window.removeEventListener('gonavi:ai:config-changed', handleAIConfigChanged as EventListener); - }; - }, [loadActiveProvider, loadMCPTools, loadSkills, loadUserPromptSettings]); - - // 监听供应商配置变更(来自设置面板的删除/新增/切换操作),重新加载 active provider 并清空已缓存的模型 - useEffect(() => { - const handler = () => { - setDynamicModels([]); - setComposerNotice(null); - activeProviderIdRef.current = null; - loadActiveProvider(); - }; - window.addEventListener('gonavi:ai:provider-changed', handler); - return () => window.removeEventListener('gonavi:ai:provider-changed', handler); - }, [loadActiveProvider]); - - const handleModelChange = async (val: string) => { - if (!activeProvider) return; - try { - const Service = (window as any).go?.aiservice?.Service; - const payload = { - ...activeProvider, - model: val, - apiKey: activeProvider.apiKey || '', - hasSecret: activeProvider.hasSecret ?? Boolean(activeProvider.secretRef), - }; - await Service?.AISaveProvider?.(payload); - setActiveProvider(payload); - setComposerNotice(null); - } catch (e) { console.warn('Failed to update provider model', e); } - }; - - const activeProviderIdRef = useRef(null); - - useEffect(() => { - if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) { - setDynamicModels([]); - setComposerNotice(null); - activeProviderIdRef.current = activeProvider.id; - } - // 供应商被删除后 activeProvider 变为 null,此时也必须清空残留模型 - if (!activeProvider) { - setDynamicModels([]); - setComposerNotice(null); - activeProviderIdRef.current = null; - } - }, [activeProvider?.id, activeProvider]); - - useEffect(() => { - if (activeProvider?.model && String(activeProvider.model).trim()) { - setComposerNotice(null); - } - }, [activeProvider?.model]); - - - // dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表 - - const fetchDynamicModels = useCallback(async () => { - try { - setLoadingModels(true); - setComposerNotice(null); - const Service = (window as any).go?.aiservice?.Service; - if (!Service) return; - const result = await Service.AIListModels?.(); - if (result?.success && Array.isArray(result.models) && result.models.length > 0) { - const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b)); - setDynamicModels(sortedModels); - setComposerNotice(null); - } else if (result && !result.success) { - setDynamicModels([]); - setComposerNotice(buildModelFetchFailedNotice(result.error)); - } - } catch (e: any) { - console.warn('Failed to fetch models', e); - setDynamicModels([]); - setComposerNotice(buildModelFetchFailedNotice('获取模型列表失败:' + (e?.message || '未知错误'))); - } finally { - setLoadingModels(false); - } - }, []); - - const handleOpenSettingsFromPanel = useCallback(() => { - onOpenSettings?.(); - window.setTimeout(() => { - void loadActiveProvider(); - }, 500); - }, [loadActiveProvider, onOpenSettings]); - - const handleComposerAction = useCallback((actionKey: AIComposerNoticeAction) => { - if (actionKey === 'open-settings') { - handleOpenSettingsFromPanel(); - return; - } - if (actionKey === 'reload-models') { - void fetchDynamicModels(); - } - }, [fetchDynamicModels, handleOpenSettingsFromPanel]); - useEffect(() => { if (messages.length === 0) return; messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' }); diff --git a/frontend/src/components/ai/useAIChatRuntimeResources.ts b/frontend/src/components/ai/useAIChatRuntimeResources.ts new file mode 100644 index 0000000..8103871 --- /dev/null +++ b/frontend/src/components/ai/useAIChatRuntimeResources.ts @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { + AIMCPToolDescriptor, + AIProviderConfig, + AISkillConfig, + AIUserPromptSettings, +} from '../../types'; +import type { AIComposerNotice, AIComposerNoticeAction } from '../../utils/aiComposerNotice'; +import { buildModelFetchFailedNotice } from '../../utils/aiComposerNotice'; + +interface AIChatRuntimeService { + AIGetProviders?: () => Promise; + AIGetActiveProvider?: () => Promise; + AIGetUserPromptSettings?: () => Promise>; + AIListMCPTools?: () => Promise; + AIGetSkills?: () => Promise; + AISaveProvider?: (provider: AIProviderConfig & { apiKey?: string; hasSecret?: boolean }) => Promise; + AIListModels?: () => Promise<{ success?: boolean; models?: string[]; error?: string } | undefined>; +} + +interface UseAIChatRuntimeResourcesOptions { + onOpenSettings?: () => void; +} + +export const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = { + global: '', + database: '', + jvm: '', + jvmDiagnostic: '', +}; + +export const useAIChatRuntimeResources = ({ + onOpenSettings, +}: UseAIChatRuntimeResourcesOptions) => { + const [activeProvider, setActiveProvider] = useState(null); + const [userPromptSettings, setUserPromptSettings] = useState(EMPTY_AI_USER_PROMPT_SETTINGS); + const [mcpTools, setMcpTools] = useState([]); + const [skills, setSkills] = useState([]); + const [dynamicModels, setDynamicModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); + const [composerNotice, setComposerNotice] = useState(null); + + const activeProviderIdRef = useRef(null); + + const getAIService = useCallback( + () => (window as any).go?.aiservice?.Service as AIChatRuntimeService | undefined, + [], + ); + + const loadActiveProvider = useCallback(async () => { + try { + const service = getAIService(); + if (!service) { + setActiveProvider(null); + return; + } + const [providers, activeProviderId] = await Promise.all([ + service.AIGetProviders?.(), + service.AIGetActiveProvider?.(), + ]); + if (!Array.isArray(providers) || !activeProviderId) { + setActiveProvider(null); + return; + } + const current = providers.find((item) => item.id === activeProviderId); + setActiveProvider(current || null); + } catch (error) { + console.warn('Failed to load active provider', error); + setActiveProvider(null); + } + }, [getAIService]); + + useEffect(() => { + void loadActiveProvider(); + }, [loadActiveProvider]); + + const loadUserPromptSettings = useCallback(async () => { + try { + const service = getAIService(); + if (!service?.AIGetUserPromptSettings) { + setUserPromptSettings(EMPTY_AI_USER_PROMPT_SETTINGS); + return; + } + const nextSettings = await service.AIGetUserPromptSettings(); + setUserPromptSettings({ + ...EMPTY_AI_USER_PROMPT_SETTINGS, + ...nextSettings, + }); + } catch (error) { + console.warn('Failed to load user prompt settings', error); + setUserPromptSettings(EMPTY_AI_USER_PROMPT_SETTINGS); + } + }, [getAIService]); + + const loadMCPTools = useCallback(async () => { + try { + const service = getAIService(); + if (!service?.AIListMCPTools) { + setMcpTools([]); + return; + } + const nextTools = await service.AIListMCPTools(); + setMcpTools(Array.isArray(nextTools) ? nextTools : []); + } catch (error) { + console.warn('Failed to load MCP tools', error); + setMcpTools([]); + } + }, [getAIService]); + + const loadSkills = useCallback(async () => { + try { + const service = getAIService(); + if (!service?.AIGetSkills) { + setSkills([]); + return; + } + const nextSkills = await service.AIGetSkills(); + setSkills(Array.isArray(nextSkills) ? nextSkills : []); + } catch (error) { + console.warn('Failed to load skills', error); + setSkills([]); + } + }, [getAIService]); + + useEffect(() => { + void loadUserPromptSettings(); + void loadMCPTools(); + void loadSkills(); + const handleAIConfigChanged = () => { + void loadUserPromptSettings(); + void loadMCPTools(); + void loadSkills(); + void loadActiveProvider(); + }; + window.addEventListener('gonavi:ai:config-changed', handleAIConfigChanged as EventListener); + return () => { + window.removeEventListener('gonavi:ai:config-changed', handleAIConfigChanged as EventListener); + }; + }, [loadActiveProvider, loadMCPTools, loadSkills, loadUserPromptSettings]); + + useEffect(() => { + const handleProviderChanged = () => { + setDynamicModels([]); + setComposerNotice(null); + activeProviderIdRef.current = null; + void loadActiveProvider(); + }; + window.addEventListener('gonavi:ai:provider-changed', handleProviderChanged); + return () => window.removeEventListener('gonavi:ai:provider-changed', handleProviderChanged); + }, [loadActiveProvider]); + + const handleModelChange = useCallback(async (model: string) => { + if (!activeProvider) { + return; + } + try { + const service = getAIService(); + const payload = { + ...activeProvider, + model, + apiKey: activeProvider.apiKey || '', + hasSecret: activeProvider.hasSecret ?? Boolean(activeProvider.secretRef), + }; + await service?.AISaveProvider?.(payload); + setActiveProvider(payload); + setComposerNotice(null); + } catch (error) { + console.warn('Failed to update provider model', error); + } + }, [activeProvider, getAIService]); + + useEffect(() => { + if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) { + setDynamicModels([]); + setComposerNotice(null); + activeProviderIdRef.current = activeProvider.id; + } + if (!activeProvider) { + setDynamicModels([]); + setComposerNotice(null); + activeProviderIdRef.current = null; + } + }, [activeProvider]); + + useEffect(() => { + if (activeProvider?.model && String(activeProvider.model).trim()) { + setComposerNotice(null); + } + }, [activeProvider?.model]); + + const fetchDynamicModels = useCallback(async () => { + try { + setLoadingModels(true); + setComposerNotice(null); + const service = getAIService(); + if (!service) { + return; + } + const result = await service.AIListModels?.(); + if (result?.success && Array.isArray(result.models) && result.models.length > 0) { + const sortedModels = [...result.models].sort((left, right) => left.localeCompare(right)); + setDynamicModels(sortedModels); + setComposerNotice(null); + return; + } + if (result && !result.success) { + setDynamicModels([]); + setComposerNotice(buildModelFetchFailedNotice(result.error)); + } + } catch (error: any) { + console.warn('Failed to fetch models', error); + setDynamicModels([]); + setComposerNotice(buildModelFetchFailedNotice(`获取模型列表失败:${error?.message || '未知错误'}`)); + } finally { + setLoadingModels(false); + } + }, [getAIService]); + + const handleOpenSettingsFromPanel = useCallback(() => { + onOpenSettings?.(); + window.setTimeout(() => { + void loadActiveProvider(); + }, 500); + }, [loadActiveProvider, onOpenSettings]); + + const handleComposerAction = useCallback((actionKey: AIComposerNoticeAction) => { + if (actionKey === 'open-settings') { + handleOpenSettingsFromPanel(); + return; + } + if (actionKey === 'reload-models') { + void fetchDynamicModels(); + } + }, [fetchDynamicModels, handleOpenSettingsFromPanel]); + + return { + activeProvider, + composerNotice, + dynamicModels, + fetchDynamicModels, + handleComposerAction, + handleModelChange, + handleOpenSettingsFromPanel, + loadingModels, + mcpTools, + setComposerNotice, + skills, + userPromptSettings, + }; +};