mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: keep desktop plugin changes visible without restarting chats
Plugin enablement already has a live CLI reload control path, so desktop now applies plugin changes by refreshing the active session instead of waiting for a future conversation startup. The server forwards reload_plugins to the active CLI session, refreshes session slash-command cache, and notifies the client. The desktop plugin store automatically reloads after mutating plugin state, and the empty-session composer refetches skills when plugin capabilities change. Constraint: Existing CLI exposes reload_plugins as the supported hot-refresh mechanism for commands, agents, plugins, and MCP state. Rejected: Start a hidden replacement CLI process | higher cost, extra process lifecycle risk, and less precise than the existing control channel. Confidence: high Scope-risk: moderate Directive: Keep plugin refresh routed through reload_plugins unless the CLI control contract is removed or changed. Tested: bun test src/server/__tests__/plugins.test.ts Tested: cd desktop && bun run test --run src/stores/pluginStore.test.ts src/__tests__/pluginsSettings.test.tsx src/pages/EmptySession.test.tsx Tested: bun run check:server Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: bun run check:desktop is blocked by a pre-existing color-mix compatibility failure in desktop/src/theme/globals.css.
This commit is contained in:
parent
4e969475f2
commit
de63d82633
@ -3,6 +3,7 @@ import type {
|
||||
PluginDetail,
|
||||
PluginListResponse,
|
||||
PluginReloadSummary,
|
||||
PluginSessionReloadSummary,
|
||||
PluginScope,
|
||||
} from '../types/plugin'
|
||||
|
||||
@ -36,10 +37,17 @@ export const pluginsApi = {
|
||||
uninstall: (payload: PluginActionPayload) =>
|
||||
api.post<{ ok: true; message: string }>('/api/plugins/uninstall', payload),
|
||||
|
||||
reload: (cwd?: string) => {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.post<{ ok: true; summary: PluginReloadSummary }>(
|
||||
`/api/plugins/reload${query}`,
|
||||
reload: (cwd?: string, sessionId?: string) => {
|
||||
const query = new URLSearchParams()
|
||||
if (cwd) query.set('cwd', cwd)
|
||||
if (sessionId) query.set('sessionId', sessionId)
|
||||
const suffix = query.size > 0 ? `?${query.toString()}` : ''
|
||||
return api.post<{
|
||||
ok: true
|
||||
summary: PluginReloadSummary
|
||||
session?: PluginSessionReloadSummary
|
||||
}>(
|
||||
`/api/plugins/reload${suffix}`,
|
||||
undefined,
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
|
||||
@ -82,7 +82,7 @@ export function PluginDetail() {
|
||||
const handleReload = async () => {
|
||||
setActionKey('reload')
|
||||
try {
|
||||
const summary = await reloadPlugins(currentWorkDir)
|
||||
const summary = await reloadPlugins(currentWorkDir, activeSessionId || undefined)
|
||||
addToast({
|
||||
type: summary.errors > 0 ? 'warning' : 'success',
|
||||
message: t('settings.plugins.reloadToast', {
|
||||
@ -247,7 +247,7 @@ export function PluginDetail() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={isApplying && actionKey === 'disable'}
|
||||
onClick={() => void runAction('disable', () => disablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
|
||||
onClick={() => void runAction('disable', () => disablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir, activeSessionId || undefined))}
|
||||
>
|
||||
{t('settings.plugins.disable')}
|
||||
</Button>
|
||||
@ -255,7 +255,7 @@ export function PluginDetail() {
|
||||
<Button
|
||||
size="sm"
|
||||
loading={isApplying && actionKey === 'enable'}
|
||||
onClick={() => void runAction('enable', () => enablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
|
||||
onClick={() => void runAction('enable', () => enablePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir, activeSessionId || undefined))}
|
||||
>
|
||||
{t('settings.plugins.enable')}
|
||||
</Button>
|
||||
@ -267,7 +267,7 @@ export function PluginDetail() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={isApplying && actionKey === 'update'}
|
||||
onClick={() => void runAction('update', () => updatePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir))}
|
||||
onClick={() => void runAction('update', () => updatePlugin(selectedPlugin.id, selectedPlugin.scope, currentWorkDir, activeSessionId || undefined))}
|
||||
>
|
||||
{t('settings.plugins.update')}
|
||||
</Button>
|
||||
@ -489,7 +489,7 @@ export function PluginDetail() {
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
setShowUninstallDialog(false)
|
||||
await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir))
|
||||
await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir, activeSessionId || undefined))
|
||||
}}
|
||||
title={t('settings.plugins.uninstall')}
|
||||
body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })}
|
||||
|
||||
@ -54,7 +54,7 @@ export function PluginList() {
|
||||
|
||||
const handleReload = async () => {
|
||||
try {
|
||||
const reloadSummary = await reloadPlugins(currentWorkDir)
|
||||
const reloadSummary = await reloadPlugins(currentWorkDir, activeSessionId || undefined)
|
||||
addToast({
|
||||
type: reloadSummary.errors > 0 ? 'warning' : 'success',
|
||||
message: t('settings.plugins.reloadToast', {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
@ -98,6 +98,7 @@ import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { usePluginStore } from '../stores/pluginStore'
|
||||
import type { RepositoryContextResult } from '../api/sessions'
|
||||
|
||||
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
|
||||
@ -146,6 +147,7 @@ describe('EmptySession', () => {
|
||||
const initialTabState = useTabStore.getInitialState()
|
||||
const initialRuntimeState = useSessionRuntimeStore.getInitialState()
|
||||
const initialUiState = useUIStore.getInitialState()
|
||||
const initialPluginState = usePluginStore.getInitialState()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -157,6 +159,7 @@ describe('EmptySession', () => {
|
||||
useTabStore.setState(initialTabState, true)
|
||||
useSessionRuntimeStore.setState(initialRuntimeState, true)
|
||||
useUIStore.setState(initialUiState, true)
|
||||
usePluginStore.setState(initialPluginState, true)
|
||||
|
||||
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
|
||||
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
||||
@ -187,6 +190,7 @@ describe('EmptySession', () => {
|
||||
useTabStore.setState(initialTabState, true)
|
||||
useSessionRuntimeStore.setState(initialRuntimeState, true)
|
||||
useUIStore.setState(initialUiState, true)
|
||||
usePluginStore.setState(initialPluginState, true)
|
||||
})
|
||||
|
||||
it('uses compact composer controls on phone-sized H5 browsers', async () => {
|
||||
@ -203,6 +207,45 @@ describe('EmptySession', () => {
|
||||
expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('refreshes empty-session slash commands after plugin reloads', async () => {
|
||||
mocks.listSkills
|
||||
.mockResolvedValueOnce({ skills: [] })
|
||||
.mockResolvedValueOnce({
|
||||
skills: [
|
||||
{
|
||||
name: 'draw:render',
|
||||
description: 'Render with the drawing plugin.',
|
||||
userInvocable: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<EmptySession />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.listSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
usePluginStore.setState({
|
||||
lastReloadSummary: {
|
||||
enabled: 1,
|
||||
disabled: 0,
|
||||
skills: 1,
|
||||
agents: 0,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
lspServers: 0,
|
||||
errors: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.listSkills).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('integrates repository launch controls into the desktop composer panel', async () => {
|
||||
render(<EmptySession />)
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { skillsApi } from '../api/skills'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { usePluginStore } from '../stores/pluginStore'
|
||||
import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
@ -103,6 +104,7 @@ export function EmptySession() {
|
||||
const setActiveView = useUIStore((state) => state.setActiveView)
|
||||
const addToast = useUIStore((state) => state.addToast)
|
||||
const currentModel = useSettingsStore((state) => state.currentModel)
|
||||
const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary)
|
||||
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
|
||||
const draftRuntimeSelectionKey = draftRuntimeSelection
|
||||
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
|
||||
@ -198,7 +200,7 @@ export function EmptySession() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [workDir])
|
||||
}, [workDir, lastPluginReloadSummary])
|
||||
|
||||
const allSlashCommands = useMemo(
|
||||
() => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS),
|
||||
|
||||
91
desktop/src/stores/pluginStore.test.ts
Normal file
91
desktop/src/stores/pluginStore.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePluginStore } from './pluginStore'
|
||||
import { pluginsApi } from '../api/plugins'
|
||||
|
||||
vi.mock('../api/plugins', () => ({
|
||||
pluginsApi: {
|
||||
list: vi.fn(),
|
||||
detail: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
update: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedPluginsApi = vi.mocked(pluginsApi)
|
||||
|
||||
describe('pluginStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedPluginsApi.list.mockResolvedValue({
|
||||
plugins: [],
|
||||
marketplaces: [],
|
||||
summary: { total: 0, enabled: 0, errorCount: 0, marketplaceCount: 0 },
|
||||
})
|
||||
mockedPluginsApi.reload.mockResolvedValue({
|
||||
ok: true,
|
||||
summary: {
|
||||
enabled: 1,
|
||||
disabled: 0,
|
||||
skills: 1,
|
||||
agents: 0,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
lspServers: 0,
|
||||
errors: 0,
|
||||
},
|
||||
session: {
|
||||
applied: true,
|
||||
commands: 1,
|
||||
agents: 0,
|
||||
plugins: 1,
|
||||
mcpServers: 0,
|
||||
errors: 0,
|
||||
},
|
||||
})
|
||||
usePluginStore.setState({
|
||||
plugins: [],
|
||||
marketplaces: [],
|
||||
summary: null,
|
||||
selectedPlugin: null,
|
||||
lastReloadSummary: null,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
isApplying: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads the active CLI session after enabling a plugin', async () => {
|
||||
mockedPluginsApi.enable.mockResolvedValue({
|
||||
ok: true,
|
||||
message: 'enabled',
|
||||
})
|
||||
|
||||
const message = await usePluginStore
|
||||
.getState()
|
||||
.enablePlugin('draw@test', 'user', '/workspace/project', 'session-1')
|
||||
|
||||
expect(message).toBe('enabled')
|
||||
expect(mockedPluginsApi.enable).toHaveBeenCalledWith({
|
||||
id: 'draw@test',
|
||||
scope: 'user',
|
||||
})
|
||||
expect(mockedPluginsApi.reload).toHaveBeenCalledWith(
|
||||
'/workspace/project',
|
||||
'session-1',
|
||||
)
|
||||
expect(usePluginStore.getState().lastReloadSummary).toEqual({
|
||||
enabled: 1,
|
||||
disabled: 0,
|
||||
skills: 1,
|
||||
agents: 0,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
lspServers: 0,
|
||||
errors: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -20,11 +20,11 @@ type PluginStore = {
|
||||
error: string | null
|
||||
fetchPlugins: (cwd?: string) => Promise<void>
|
||||
fetchPluginDetail: (id: string, cwd?: string) => Promise<void>
|
||||
reloadPlugins: (cwd?: string) => Promise<PluginReloadSummary>
|
||||
enablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
|
||||
disablePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
|
||||
updatePlugin: (id: string, scope?: PluginScope, cwd?: string) => Promise<string>
|
||||
uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string) => Promise<string>
|
||||
reloadPlugins: (cwd?: string, sessionId?: string) => Promise<PluginReloadSummary>
|
||||
enablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
|
||||
disablePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
|
||||
updatePlugin: (id: string, scope?: PluginScope, cwd?: string, sessionId?: string) => Promise<string>
|
||||
uninstallPlugin: (id: string, scope?: PluginScope, keepData?: boolean, cwd?: string, sessionId?: string) => Promise<string>
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
@ -70,10 +70,10 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
reloadPlugins: async (cwd) => {
|
||||
reloadPlugins: async (cwd, sessionId) => {
|
||||
set({ isApplying: true, error: null })
|
||||
try {
|
||||
const { summary } = await pluginsApi.reload(cwd)
|
||||
const { summary } = await pluginsApi.reload(cwd, sessionId)
|
||||
await get().fetchPlugins(cwd)
|
||||
const selected = get().selectedPlugin
|
||||
if (selected) {
|
||||
@ -88,39 +88,43 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
enablePlugin: async (id, scope, cwd) => {
|
||||
enablePlugin: async (id, scope, cwd, sessionId) => {
|
||||
return runAction(
|
||||
() => pluginsApi.enable({ id, scope }),
|
||||
set,
|
||||
get,
|
||||
cwd,
|
||||
sessionId,
|
||||
)
|
||||
},
|
||||
|
||||
disablePlugin: async (id, scope, cwd) => {
|
||||
disablePlugin: async (id, scope, cwd, sessionId) => {
|
||||
return runAction(
|
||||
() => pluginsApi.disable({ id, scope }),
|
||||
set,
|
||||
get,
|
||||
cwd,
|
||||
sessionId,
|
||||
)
|
||||
},
|
||||
|
||||
updatePlugin: async (id, scope, cwd) => {
|
||||
updatePlugin: async (id, scope, cwd, sessionId) => {
|
||||
return runAction(
|
||||
() => pluginsApi.update({ id, scope }),
|
||||
set,
|
||||
get,
|
||||
cwd,
|
||||
sessionId,
|
||||
)
|
||||
},
|
||||
|
||||
uninstallPlugin: async (id, scope, keepData = false, cwd) => {
|
||||
uninstallPlugin: async (id, scope, keepData = false, cwd, sessionId) => {
|
||||
return runAction(
|
||||
() => pluginsApi.uninstall({ id, scope, keepData }),
|
||||
set,
|
||||
get,
|
||||
cwd,
|
||||
sessionId,
|
||||
true,
|
||||
)
|
||||
},
|
||||
@ -133,11 +137,13 @@ async function runAction(
|
||||
set: (updater: Partial<PluginStore>) => void,
|
||||
get: () => PluginStore,
|
||||
cwd?: string,
|
||||
sessionId?: string,
|
||||
clearSelection = false,
|
||||
): Promise<string> {
|
||||
set({ isApplying: true, error: null })
|
||||
try {
|
||||
const { message } = await action()
|
||||
const { summary } = await pluginsApi.reload(cwd, sessionId)
|
||||
await get().fetchPlugins(cwd)
|
||||
const selected = get().selectedPlugin
|
||||
if (clearSelection) {
|
||||
@ -145,7 +151,7 @@ async function runAction(
|
||||
} else if (selected) {
|
||||
await get().fetchPluginDetail(selected.id, cwd)
|
||||
}
|
||||
set({ isApplying: false })
|
||||
set({ isApplying: false, lastReloadSummary: summary })
|
||||
return message
|
||||
} catch (err) {
|
||||
set({
|
||||
|
||||
@ -99,3 +99,14 @@ export type PluginReloadSummary = {
|
||||
lspServers: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export type PluginSessionReloadSummary = {
|
||||
applied: boolean
|
||||
reason?: 'not_running' | 'failed'
|
||||
commands: number
|
||||
agents: number
|
||||
plugins: number
|
||||
mcpServers: number
|
||||
errors: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
@ -7,9 +7,13 @@ import { clearInstalledPluginsCache } from '../../utils/plugins/installedPlugins
|
||||
import { clearPluginCache, loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
|
||||
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||
import { handlePluginsApi } from '../api/plugins.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
import { __resetWebSocketHandlerStateForTests, getSlashCommands } from '../ws/handler.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let originalHasSession: typeof conversationService.hasSession
|
||||
let originalRequestControl: typeof conversationService.requestControl
|
||||
|
||||
function makeRequest(
|
||||
method: string,
|
||||
@ -38,9 +42,15 @@ describe('Plugins API', () => {
|
||||
clearInstalledPluginsCache()
|
||||
clearPluginCache('plugins-api-test-setup')
|
||||
resetSettingsCache()
|
||||
__resetWebSocketHandlerStateForTests()
|
||||
originalHasSession = conversationService.hasSession.bind(conversationService)
|
||||
originalRequestControl = conversationService.requestControl.bind(conversationService)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
conversationService.hasSession = originalHasSession
|
||||
conversationService.requestControl = originalRequestControl
|
||||
__resetWebSocketHandlerStateForTests()
|
||||
clearInstalledPluginsCache()
|
||||
clearPluginCache('plugins-api-test-teardown')
|
||||
resetSettingsCache()
|
||||
@ -166,4 +176,69 @@ describe('Plugins API', () => {
|
||||
expect(typeof body.summary.skills).toBe('number')
|
||||
expect(typeof body.summary.errors).toBe('number')
|
||||
})
|
||||
|
||||
it('POST /api/plugins/reload hot-reloads an active CLI session and updates slash commands', async () => {
|
||||
const controlRequests: Array<{ sessionId: string; request: Record<string, unknown> }> = []
|
||||
conversationService.hasSession = ((sessionId: string) => sessionId === 'session-plugins') as typeof conversationService.hasSession
|
||||
conversationService.requestControl = (async (
|
||||
sessionId: string,
|
||||
request: Record<string, unknown>,
|
||||
) => {
|
||||
controlRequests.push({ sessionId, request })
|
||||
return {
|
||||
commands: [
|
||||
{
|
||||
name: 'draw:render',
|
||||
description: 'Render a drawing.',
|
||||
argumentHint: '<prompt>',
|
||||
},
|
||||
],
|
||||
agents: [{ name: 'draw-agent' }],
|
||||
plugins: [{ name: 'draw', path: '/tmp/draw', source: 'draw@test' }],
|
||||
mcpServers: [{ name: 'plugin:draw:server', type: 'connected' }],
|
||||
error_count: 0,
|
||||
}
|
||||
}) as typeof conversationService.requestControl
|
||||
|
||||
const { req, url, segments } = makeRequest(
|
||||
'POST',
|
||||
'/api/plugins/reload?sessionId=session-plugins',
|
||||
{},
|
||||
)
|
||||
const res = await handlePluginsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {
|
||||
session: {
|
||||
applied: boolean
|
||||
commands: number
|
||||
agents: number
|
||||
plugins: number
|
||||
mcpServers: number
|
||||
errors: number
|
||||
}
|
||||
}
|
||||
|
||||
expect(controlRequests).toEqual([
|
||||
{
|
||||
sessionId: 'session-plugins',
|
||||
request: { subtype: 'reload_plugins' },
|
||||
},
|
||||
])
|
||||
expect(body.session).toEqual({
|
||||
applied: true,
|
||||
commands: 1,
|
||||
agents: 1,
|
||||
plugins: 1,
|
||||
mcpServers: 1,
|
||||
errors: 0,
|
||||
})
|
||||
expect(getSlashCommands('session-plugins')).toEqual([
|
||||
{
|
||||
name: 'draw:render',
|
||||
description: 'Render a drawing.',
|
||||
argumentHint: '<prompt>',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,22 @@
|
||||
import type { PluginScope } from '../../utils/plugins/schemas.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { PluginService } from '../services/pluginService.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
import { updateSessionSlashCommands } from '../ws/handler.js'
|
||||
|
||||
const pluginService = new PluginService()
|
||||
|
||||
type PluginSessionReloadSummary = {
|
||||
applied: boolean
|
||||
reason?: 'not_running' | 'failed'
|
||||
commands: number
|
||||
agents: number
|
||||
plugins: number
|
||||
mcpServers: number
|
||||
errors: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function handlePluginsApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
@ -29,7 +42,16 @@ export async function handlePluginsApi(
|
||||
}
|
||||
|
||||
if (method === 'POST' && sub === 'reload') {
|
||||
return Response.json(await pluginService.reloadPlugins(cwd))
|
||||
const sessionId = url.searchParams.get('sessionId') || undefined
|
||||
const response = await pluginService.reloadPlugins(cwd)
|
||||
if (!sessionId) {
|
||||
return Response.json(response)
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
...response,
|
||||
session: await reloadSessionPlugins(sessionId),
|
||||
})
|
||||
}
|
||||
|
||||
if (method === 'POST' && sub) {
|
||||
@ -73,6 +95,52 @@ export async function handlePluginsApi(
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadSessionPlugins(
|
||||
sessionId: string,
|
||||
): Promise<PluginSessionReloadSummary> {
|
||||
if (!conversationService.hasSession(sessionId)) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: 'not_running',
|
||||
commands: 0,
|
||||
agents: 0,
|
||||
plugins: 0,
|
||||
mcpServers: 0,
|
||||
errors: 0,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await conversationService.requestControl(
|
||||
sessionId,
|
||||
{ subtype: 'reload_plugins' },
|
||||
120_000,
|
||||
)
|
||||
const commands = Array.isArray(response.commands) ? response.commands : []
|
||||
const normalizedCommands = updateSessionSlashCommands(sessionId, commands)
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
commands: normalizedCommands.length,
|
||||
agents: Array.isArray(response.agents) ? response.agents.length : 0,
|
||||
plugins: Array.isArray(response.plugins) ? response.plugins.length : 0,
|
||||
mcpServers: Array.isArray(response.mcpServers) ? response.mcpServers.length : 0,
|
||||
errors: typeof response.error_count === 'number' ? response.error_count : 0,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: 'failed',
|
||||
commands: 0,
|
||||
agents: 0,
|
||||
plugins: 0,
|
||||
mcpServers: 0,
|
||||
errors: 0,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
|
||||
@ -32,7 +32,13 @@ const providerService = new ProviderService()
|
||||
/**
|
||||
* Cache slash commands from CLI init messages, keyed by sessionId.
|
||||
*/
|
||||
const sessionSlashCommands = new Map<string, Array<{ name: string; description: string }>>()
|
||||
export type SessionSlashCommand = {
|
||||
name: string
|
||||
description: string
|
||||
argumentHint?: string
|
||||
}
|
||||
|
||||
const sessionSlashCommands = new Map<string, SessionSlashCommand[]>()
|
||||
|
||||
/**
|
||||
* Timers for delayed session cleanup after client disconnect.
|
||||
@ -86,7 +92,7 @@ async function sendRepositoryStartupStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> {
|
||||
export function getSlashCommands(sessionId: string): SessionSlashCommand[] {
|
||||
return sessionSlashCommands.get(sessionId) || []
|
||||
}
|
||||
|
||||
@ -785,10 +791,7 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
|
||||
})()
|
||||
}
|
||||
if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
|
||||
sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({
|
||||
name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''),
|
||||
description: typeof cmd === 'string' ? '' : (cmd.description || ''),
|
||||
})))
|
||||
updateSessionSlashCommands(sessionId, cliMsg.slash_commands, { notifyClient: false })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1568,6 +1571,55 @@ export function sendToSession(sessionId: string, message: ServerMessage): boolea
|
||||
return true
|
||||
}
|
||||
|
||||
export function updateSessionSlashCommands(
|
||||
sessionId: string,
|
||||
commands: unknown[],
|
||||
options: { notifyClient?: boolean } = {},
|
||||
): SessionSlashCommand[] {
|
||||
const normalized = commands
|
||||
.map(normalizeSessionSlashCommand)
|
||||
.filter((command): command is SessionSlashCommand => command !== null)
|
||||
|
||||
sessionSlashCommands.set(sessionId, normalized)
|
||||
|
||||
if (options.notifyClient !== false) {
|
||||
sendToSession(sessionId, {
|
||||
type: 'system_notification',
|
||||
subtype: 'slash_commands',
|
||||
data: normalized,
|
||||
})
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeSessionSlashCommand(command: unknown): SessionSlashCommand | null {
|
||||
if (typeof command === 'string') {
|
||||
return command.trim() ? { name: command, description: '' } : null
|
||||
}
|
||||
if (!command || typeof command !== 'object') return null
|
||||
|
||||
const record = command as {
|
||||
name?: unknown
|
||||
command?: unknown
|
||||
description?: unknown
|
||||
argumentHint?: unknown
|
||||
}
|
||||
const name =
|
||||
typeof record.name === 'string'
|
||||
? record.name
|
||||
: typeof record.command === 'string'
|
||||
? record.command
|
||||
: ''
|
||||
if (!name.trim()) return null
|
||||
|
||||
return {
|
||||
name,
|
||||
description: typeof record.description === 'string' ? record.description : '',
|
||||
...(typeof record.argumentHint === 'string' ? { argumentHint: record.argumentHint } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function closeSessionConnection(sessionId: string, reason = 'session closed'): boolean {
|
||||
const cleanupTimer = sessionCleanupTimers.get(sessionId)
|
||||
if (cleanupTimer) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user