♻️ refactor(ai-chat): 抽离运行时资源加载与设置同步

- 新增 useAIChatRuntimeResources 管理供应商、模型、MCP 工具和 Skills 加载
- 收拢 AI 设置事件监听与模型列表刷新逻辑,减少面板内部副作用堆叠
- 保持 AI 面板行为不变,并通过定向测试、构建和真实页面路径复验
This commit is contained in:
Syngnat
2026-06-09 01:44:20 +08:00
parent 0a48f70643
commit bffad0c3a3
3 changed files with 273 additions and 198 deletions

View File

@@ -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');

View File

@@ -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<AIChatPanelProps> = ({
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
}) => {
const [input, setInput] = useState('');
const [draftImages, setDraftImages] = useState<string[]>([]);
const [sending, setSending] = useState(false);
const [activeProvider, setActiveProvider] = useState<AIProviderConfig | null>(null);
const [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
const [mcpTools, setMcpTools] = useState<AIMCPToolDescriptor[]>([]);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [loadingModels, setLoadingModels] = useState(false);
const [composerNotice, setComposerNotice] = useState<AIComposerNotice | null>(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<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -260,180 +255,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
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<string | null>(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' });

View File

@@ -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<AIProviderConfig[]>;
AIGetActiveProvider?: () => Promise<string>;
AIGetUserPromptSettings?: () => Promise<Partial<AIUserPromptSettings>>;
AIListMCPTools?: () => Promise<AIMCPToolDescriptor[]>;
AIGetSkills?: () => Promise<AISkillConfig[]>;
AISaveProvider?: (provider: AIProviderConfig & { apiKey?: string; hasSecret?: boolean }) => Promise<unknown>;
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<AIProviderConfig | null>(null);
const [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
const [mcpTools, setMcpTools] = useState<AIMCPToolDescriptor[]>([]);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [composerNotice, setComposerNotice] = useState<AIComposerNotice | null>(null);
const activeProviderIdRef = useRef<string | null>(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,
};
};