302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
import { useCallback, useEffect } from 'react'
|
||
import type { Command } from '../commands.js'
|
||
import { useNotifications } from '../context/notifications.js'
|
||
import {
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
logEvent,
|
||
} from '../services/analytics/index.js'
|
||
import { reinitializeLspServerManager } from '../services/lsp/manager.js'
|
||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
|
||
import { count } from '../utils/array.js'
|
||
import { logForDebugging } from '../utils/debug.js'
|
||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||
import { toError } from '../utils/errors.js'
|
||
import { logError } from '../utils/log.js'
|
||
import { loadPluginAgents } from '../utils/plugins/loadPluginAgents.js'
|
||
import { getPluginCommands } from '../utils/plugins/loadPluginCommands.js'
|
||
import { loadPluginHooks } from '../utils/plugins/loadPluginHooks.js'
|
||
import { loadPluginLspServers } from '../utils/plugins/lspPluginIntegration.js'
|
||
import { loadPluginMcpServers } from '../utils/plugins/mcpPluginIntegration.js'
|
||
import { detectAndUninstallDelistedPlugins } from '../utils/plugins/pluginBlocklist.js'
|
||
import { getFlaggedPlugins } from '../utils/plugins/pluginFlagging.js'
|
||
import { loadAllPlugins } from '../utils/plugins/pluginLoader.js'
|
||
|
||
/**
|
||
* 管理插件状态并与 AppState 同步的 Hook。
|
||
*
|
||
* 挂载时:加载所有插件,运行除名 enforcement,显示标记插件
|
||
* 通知,填充 AppState.plugins。这是初始 Layer-3 加载 —
|
||
* 后续刷新通过 /reload-plugins 进行。
|
||
*
|
||
* 需要刷新时:显示通知引导用户运行 /reload-plugins。
|
||
* 不会自动刷新。所有 Layer-3 交换(命令、代理、hooks、MCP)
|
||
* 都通过 refreshActivePlugins() 进行,以保持一致的心智模型。
|
||
* 参见 Outline: declarative-settings-hXHBMDIf4b PR 5c。
|
||
*/
|
||
export function useManagePlugins({
|
||
enabled = true,
|
||
}: {
|
||
enabled?: boolean
|
||
} = {}) {
|
||
const setAppState = useSetAppState()
|
||
const needsRefresh = useAppState(s => s.plugins.needsRefresh)
|
||
const { addNotification } = useNotifications()
|
||
|
||
// 初始插件加载。挂载时运行一次。不用于刷新 —
|
||
// 所有挂载后刷新都通过 /reload-plugins → refreshActivePlugins() 进行。
|
||
// 与 refreshActivePlugins 不同,这也运行除名 enforcement 和
|
||
// 标记插件通知(会话启动关注点),并且不会增加
|
||
// mcp.pluginReconnectKey(MCP 效果在自己的挂载上触发)。
|
||
const initialPluginLoad = useCallback(async () => {
|
||
try {
|
||
// 加载所有插件 - 捕获错误数组
|
||
const { enabled, disabled, errors } = await loadAllPlugins()
|
||
|
||
// 检测已除名的插件,自动卸载并记录为标记。
|
||
await detectAndUninstallDelistedPlugins()
|
||
|
||
// 如果有标记的插件待处理则通知
|
||
const flagged = getFlaggedPlugins()
|
||
if (Object.keys(flagged).length > 0) {
|
||
addNotification({
|
||
key: 'plugin-delisted-flagged',
|
||
text: 'Plugins flagged. Check /plugins',
|
||
color: 'warning',
|
||
priority: 'high',
|
||
})
|
||
}
|
||
|
||
// 加载命令、代理和 hooks,带有单独的錯誤處理
|
||
// 错误被添加到错误数组中,以便在 Doctor UI 中对用户可见
|
||
let commands: Command[] = []
|
||
let agents: AgentDefinition[] = []
|
||
|
||
try {
|
||
commands = await getPluginCommands()
|
||
} catch (error) {
|
||
const errorMessage =
|
||
error instanceof Error ? error.message : String(error)
|
||
errors.push({
|
||
type: 'generic-error',
|
||
source: 'plugin-commands',
|
||
error: `Failed to load plugin commands: ${errorMessage}`,
|
||
})
|
||
}
|
||
|
||
try {
|
||
agents = await loadPluginAgents()
|
||
} catch (error) {
|
||
const errorMessage =
|
||
error instanceof Error ? error.message : String(error)
|
||
errors.push({
|
||
type: 'generic-error',
|
||
source: 'plugin-agents',
|
||
error: `Failed to load plugin agents: ${errorMessage}`,
|
||
})
|
||
}
|
||
|
||
try {
|
||
await loadPluginHooks()
|
||
} catch (error) {
|
||
const errorMessage =
|
||
error instanceof Error ? error.message : String(error)
|
||
errors.push({
|
||
type: 'generic-error',
|
||
source: 'plugin-hooks',
|
||
error: `Failed to load plugin hooks: ${errorMessage}`,
|
||
})
|
||
}
|
||
|
||
// 加载每个插件的 MCP 服务器配置以获取准确计数。
|
||
// LoadedPlugin.mcpServers 不是由 loadAllPlugins 填充的 — 它是一个
|
||
// 缓存槽,extractMcpServersFromPlugins 稍后填充,这与这个指标竞争。
|
||
// 直接调用 loadPluginMcpServers(如同 cli/handlers/plugins.ts 所做)
|
||
// 可以获得正确的计数,并且还可以为 MCP 连接管理器预热缓存。
|
||
//
|
||
// 在 setAppState 之前运行,以便这些加载器推送的错误进入
|
||
// AppState.plugins.errors(Doctor UI),而不仅仅是遥测。
|
||
const mcpServerCounts = await Promise.all(
|
||
enabled.map(async p => {
|
||
if (p.mcpServers) return Object.keys(p.mcpServers).length
|
||
const servers = await loadPluginMcpServers(p, errors)
|
||
if (servers) p.mcpServers = servers
|
||
return servers ? Object.keys(servers).length : 0
|
||
}),
|
||
)
|
||
const mcp_count = mcpServerCounts.reduce((sum, n) => sum + n, 0)
|
||
|
||
// LSP:#15521 的主要修复在 refresh.ts(通过
|
||
// performBackgroundPluginInstallations → refreshActivePlugins,
|
||
// 它首先清除缓存)。这个重新初始化是防御性的 — 它读取与原始初始化
|
||
// 相同的 memoized loadAllPlugins() 结果,除非在 main.tsx:3203 和
|
||
// REPL 挂载之间发生了缓存失效(例如 seed marketplace 注册或
|
||
// policySettings 热重载)。
|
||
const lspServerCounts = await Promise.all(
|
||
enabled.map(async p => {
|
||
if (p.lspServers) return Object.keys(p.lspServers).length
|
||
const servers = await loadPluginLspServers(p, errors)
|
||
if (servers) p.lspServers = servers
|
||
return servers ? Object.keys(servers).length : 0
|
||
}),
|
||
)
|
||
const lsp_count = lspServerCounts.reduce((sum, n) => sum + n, 0)
|
||
reinitializeLspServerManager()
|
||
|
||
// 更新 AppState - 合并错误以保留 LSP 错误
|
||
setAppState(prevState => {
|
||
// 保留现有的 LSP/非插件加载错误(source 为 'lsp-manager' 或 'plugin:*')
|
||
const existingLspErrors = prevState.plugins.errors.filter(
|
||
e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'),
|
||
)
|
||
// 去重:删除也存在于新错误中的现有 LSP 错误
|
||
const newErrorKeys = new Set(
|
||
errors.map(e =>
|
||
e.type === 'generic-error'
|
||
? `generic-error:${e.source}:${e.error}`
|
||
: `${e.type}:${e.source}`,
|
||
),
|
||
)
|
||
const filteredExisting = existingLspErrors.filter(e => {
|
||
const key =
|
||
e.type === 'generic-error'
|
||
? `generic-error:${e.source}:${e.error}`
|
||
: `${e.type}:${e.source}`
|
||
return !newErrorKeys.has(key)
|
||
})
|
||
const mergedErrors = [...filteredExisting, ...errors]
|
||
|
||
return {
|
||
...prevState,
|
||
plugins: {
|
||
...prevState.plugins,
|
||
enabled,
|
||
disabled,
|
||
commands,
|
||
errors: mergedErrors,
|
||
},
|
||
}
|
||
})
|
||
|
||
logForDebugging(
|
||
`Loaded plugins - Enabled: ${enabled.length}, Disabled: ${disabled.length}, Commands: ${commands.length}, Agents: ${agents.length}, Errors: ${errors.length}`,
|
||
)
|
||
|
||
// 跨启用插件计数组件类型
|
||
const hook_count = enabled.reduce((sum, p) => {
|
||
if (!p.hooksConfig) return sum
|
||
return (
|
||
sum +
|
||
Object.values(p.hooksConfig).reduce(
|
||
(s, matchers) =>
|
||
s + (matchers?.reduce((h, m) => h + m.hooks.length, 0) ?? 0),
|
||
0,
|
||
)
|
||
)
|
||
}, 0)
|
||
|
||
return {
|
||
enabled_count: enabled.length,
|
||
disabled_count: disabled.length,
|
||
inline_count: count(enabled, p => p.source.endsWith('@inline')),
|
||
marketplace_count: count(enabled, p => !p.source.endsWith('@inline')),
|
||
error_count: errors.length,
|
||
skill_count: commands.length,
|
||
agent_count: agents.length,
|
||
hook_count,
|
||
mcp_count,
|
||
lsp_count,
|
||
// 仅 Ant:启用了哪些插件,以与 RSS/FPS 相关。
|
||
// 与基本指标分开,因此不会流入 logForDiagnosticsNoPII。
|
||
ant_enabled_names:
|
||
process.env.USER_TYPE === 'ant' && enabled.length > 0
|
||
? (enabled
|
||
.map(p => p.name)
|
||
.sort()
|
||
.join(
|
||
',',
|
||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||
: undefined,
|
||
}
|
||
} catch (error) {
|
||
// 只有插件加载错误应该到达这里 - 记录用于监控
|
||
const errorObj = toError(error)
|
||
logError(errorObj)
|
||
logForDebugging(`Error loading plugins: ${error}`)
|
||
// 出错时设置空状态,但保留 LSP 错误并添加新错误
|
||
setAppState(prevState => {
|
||
// 保留现有的 LSP/非插件加载错误
|
||
const existingLspErrors = prevState.plugins.errors.filter(
|
||
e => e.source === 'lsp-manager' || e.source.startsWith('plugin:'),
|
||
)
|
||
const newError = {
|
||
type: 'generic-error' as const,
|
||
source: 'plugin-system',
|
||
error: errorObj.message,
|
||
}
|
||
return {
|
||
...prevState,
|
||
plugins: {
|
||
...prevState.plugins,
|
||
enabled: [],
|
||
disabled: [],
|
||
commands: [],
|
||
errors: [...existingLspErrors, newError],
|
||
},
|
||
}
|
||
})
|
||
|
||
return {
|
||
enabled_count: 0,
|
||
disabled_count: 0,
|
||
inline_count: 0,
|
||
marketplace_count: 0,
|
||
error_count: 1,
|
||
skill_count: 0,
|
||
agent_count: 0,
|
||
hook_count: 0,
|
||
mcp_count: 0,
|
||
lsp_count: 0,
|
||
load_failed: true,
|
||
ant_enabled_names: undefined,
|
||
}
|
||
}
|
||
}, [setAppState, addNotification])
|
||
|
||
// 挂载时加载插件并发出遥测
|
||
useEffect(() => {
|
||
if (!enabled) return
|
||
void initialPluginLoad().then(metrics => {
|
||
const { ant_enabled_names, ...baseMetrics } = metrics
|
||
const allMetrics = {
|
||
...baseMetrics,
|
||
has_custom_plugin_cache_dir: !!process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR,
|
||
}
|
||
logEvent('tengu_plugins_loaded', {
|
||
...allMetrics,
|
||
...(ant_enabled_names !== undefined && {
|
||
enabled_names: ant_enabled_names,
|
||
}),
|
||
})
|
||
logForDiagnosticsNoPII('info', 'tengu_plugins_loaded', allMetrics)
|
||
})
|
||
}, [initialPluginLoad, enabled])
|
||
|
||
// 插件状态在磁盘上更改(后台协调,/plugin 菜单,
|
||
// 外部设置编辑)。显示通知;用户运行 /reload-plugins 应用。
|
||
// 之前的自动刷新有陈旧缓存 bug(只清除 loadAllPlugins,
|
||
// 下游 memoized 加载器返回旧数据)而且不完整(无 MCP,无 agentDefinitions)。
|
||
// /reload-plugins 通过 refreshActivePlugins() 正确处理所有这些。
|
||
useEffect(() => {
|
||
if (!enabled || !needsRefresh) return
|
||
addNotification({
|
||
key: 'plugin-reload-pending',
|
||
text: 'Plugins changed. Run /reload-plugins to activate.',
|
||
color: 'suggestion',
|
||
priority: 'low',
|
||
})
|
||
// 不要自动刷新。不要重置 needsRefresh — /reload-plugins
|
||
// 通过 refreshActivePlugins() 消费它。
|
||
}, [enabled, needsRefresh, addNotification])
|
||
}
|