diff --git a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts new file mode 100644 index 00000000..8dcf8c6d --- /dev/null +++ b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts @@ -0,0 +1,33 @@ +import type { RuntimeRequestHandler } from '../../../../shared/runtime/rpcPeer' +import { expect, it, vi } from 'vitest' +import { EXTENSION_CAPABILITIES_RPC, extensionCapabilitiesSchema } from '../../../../shared/extensions/extensionAuthoring' +import { EXTENSION_API_VERSION, extensionPlacementSchema } from '../../../../shared/extensions/extensionManifest' +import { registerExtensionAuthoringRpc } from '../registerExtensionAuthoringRpc' + +vi.mock('../compileExtension', () => ({ compileExtension: () => { + throw new Error('Unexpected compilation') +} })) + +it('discovers a compact host index and exact target contracts without loading plugins', async () => { + const handlers = new Map() + const dispose = registerExtensionAuthoringRpc({ onRequest: (method, handler) => { + handlers.set(method, handler) + return () => handlers.delete(method) + } }, async () => { throw new Error('Unexpected inspection') }) + try { + const query = (params: unknown) => Promise.resolve(handlers.get(EXTENSION_CAPABILITIES_RPC)!(params)) + const index = extensionCapabilitiesSchema.parse(await query({})) + expect(index.apiVersion).toBe(EXTENSION_API_VERSION) + expect(index.targets.some(target => target.target === 'composer.accessory' && target.scope === 'composer')).toBe(true) + expect(index.targets.every(target => !target.description && !target.height)).toBe(true) + const detail = extensionCapabilitiesSchema.parse(await query({ target: 'composer.accessory' })) + expect(detail.targets).toEqual([expect.objectContaining({ kind: 'slot', selection: 'multiple', height: { min: 32, max: 240, default: 64 } })]) + const placement = { id: 'tests.content.slot', view: 'tests.content.view', kind: 'slot', target: detail.targets[0]!.target } + expect(extensionPlacementSchema.parse(placement)).toMatchObject({ height: detail.targets[0]!.height!.default }) + expect(extensionPlacementSchema.safeParse({ ...placement, height: detail.targets[0]!.height!.max + 1 }).success).toBe(false) + expect(extensionCapabilitiesSchema.parse(await query({ target: 'document.body' })).targets).toEqual([]) + expect(extensionCapabilitiesSchema.parse(await query({ kind: 'runtime' })).targets.map(target => target.target)).toEqual(['commands', 'workbench.panes', 'workbench.interactions']) + await expect(query({ kind: 'arbitrary' })).rejects.toThrow() + } + finally { dispose() } +}) diff --git a/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts b/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts index 78448c47..aa6a1e69 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts @@ -1,13 +1,20 @@ import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring' import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import { buildExtensionPackage } from '../../../platform/extensions/buildExtensionPackage' -import { EXTENSION_BUILD_RPC, EXTENSION_INSPECT_RPC, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring' +import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_INSPECT_RPC, extensionCapabilitiesRequestSchema, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring' +import { EXTENSION_API_VERSION } from '../../../shared/extensions/extensionManifest' +import { queryWorkbenchCapabilities } from '../../../shared/workbench/workbenchUi' import { compileExtension } from './compileExtension' -export function registerExtensionAuthoringRpc(peer: RuntimeRpcPeerContract, inspect: (id: string) => Promise): () => void { +export function registerExtensionAuthoringRpc(peer: Pick, inspect: (id: string) => Promise): () => void { const lifetime = new AbortController() let active = 0 const stopInspect = peer.onRequest(EXTENSION_INSPECT_RPC, async input => extensionInspectionSchema.parse(await inspect(extensionInspectRequestSchema.parse(input).id))) + const stopCapabilities = peer.onRequest(EXTENSION_CAPABILITIES_RPC, async (input) => { + const query = extensionCapabilitiesRequestSchema.parse(input) + const targets = queryWorkbenchCapabilities(query) + return { apiVersion: EXTENSION_API_VERSION, targets: query.kind || query.target ? targets : targets.map(({ kind, target, title, scope }) => ({ kind, target, title, scope })) } + }) const stop = peer.onRequest(EXTENSION_BUILD_RPC, async (input, signal) => { if (active >= 2) return { ok: false, code: 'EXTENSION_COMPILER_BUSY', diagnostics: [] } @@ -21,5 +28,6 @@ export function registerExtensionAuthoringRpc(peer: RuntimeRpcPeerContract, insp lifetime.abort() stop() stopInspect() + stopCapabilities() } } diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts index ac6d8677..73ac0581 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts @@ -181,6 +181,10 @@ export function registerExtensionIpc(options: { case 'devtools': return await service.devtools(input.id) case 'revokeResources': return await service.revokeResources(input.id) case 'execute': return await service.execute(input.id, input.command, input.resource) + case 'executeMenu': return await service.executeMenu(input.id, input.menu, input.invocation) + case 'executeSlash': return await service.executeSlash(input.id, input.command, input.arguments, input.instanceId) + case 'updatePanes': return service.updatePanes(input.panes) + case 'endInteraction': return service.endInteraction(input.id) case 'openView': return await service.openView(input.view) case 'closeView': return service.closeView(input.viewId, input.generation, input.token) case 'viewRequest': return await service.viewRequest(input.viewId, input.generation, input.token, input.method, input.params) diff --git a/apps/buddy/electron/main/extensions/runtime/host.js b/apps/buddy/electron/main/extensions/runtime/host.js index 03a9aeff..c2b90f11 100644 --- a/apps/buddy/electron/main/extensions/runtime/host.js +++ b/apps/buddy/electron/main/extensions/runtime/host.js @@ -4,7 +4,15 @@ const subscriptions = new Set() let entry let manifest let context +let registrationError = null let activated = false +let panes = Object.freeze([]) +const paneListeners = new Set() +const interactions = new Map() +function applyPanes(next) { + panes = Object.freeze(next.map(pane => Object.freeze({ ...pane, rect: Object.freeze(pane.rect) }))) + for (const listener of paneListeners) listener(panes) +} const code = error => /^EXTENSION_[A-Z_]+$/.test(error?.message) ? error.message : 'EXTENSION_ACTIVATION_FAILED' const request = (method, params = null) => bridge.request(method, params) function disposable(cleanup) { @@ -24,6 +32,7 @@ bridge.subscribe(async ({ id, method, params }) => { let result = null if (method === 'activate') { manifest = params.manifest + applyPanes(params.panes ?? []) entry = manifest.entry ? await import(`/__package/${manifest.entry}`) : {} context = Object.freeze({ extension: Object.freeze({ id: manifest.id, version: manifest.version, apiVersion: manifest.apiVersion }), @@ -32,13 +41,38 @@ bridge.subscribe(async ({ id, method, params }) => { return value } }, commands: { register(id, callback) { - if (!manifest.contributes.commands.some(command => command.id === id) || commands.has(id) || typeof callback !== 'function') - throw new Error('EXTENSION_COMMAND_INVALID') + if (activated || !manifest.contributes.commands.some(command => command.id === id) || commands.has(id) || typeof callback !== 'function') { + registrationError = new Error('EXTENSION_COMMAND_INVALID') + throw registrationError + } commands.set(id, callback) return disposable(() => commands.delete(id)) } }, - views: { open: (type, options = {}) => request('views.open', { type, resource: options.resource ?? null, state: options.state ?? {} }) }, - placements: { show: id => request('placements.show', { id }), hide: id => request('placements.hide', { id }) }, + workbench: { + get panes() { return panes }, + onPanesChange: (listener) => { + paneListeners.add(listener) + return disposable(() => paneListeners.delete(listener)) + }, + }, + interactions: { start: async (title) => { + const id = crypto.randomUUID() + const controller = new AbortController() + interactions.set(id, controller) + try { + await request('interactions.start', { id, title }) + if (controller.signal.aborted) + throw new Error('EXTENSION_INTERACTION_ENDED') + return Object.freeze({ id, signal: controller.signal, end: () => request('interactions.end', { id }) }) + } + catch (error) { + controller.abort() + interactions.delete(id) + throw error + } + } }, + views: { broadcast: message => request('views.broadcast', message), open: (type, options = {}) => request('views.open', { type, resource: options.resource ?? null, state: options.state ?? {} }) }, + placements: { show: (id, options) => request('placements.show', { id, ...(typeof options === 'string' ? { instanceId: options } : options ?? {}) }), hide: (id, options) => request('placements.hide', { id, ...(typeof options === 'string' ? { instanceId: options } : options ?? {}) }) }, resources: { readText: resource => request('resources.readText', { id: resource.id }) }, storage: { get: () => request('storage.get'), set: value => request('storage.set', { value, version: manifest.dataVersion }) }, network: { get: url => request('network.get', { url }) }, @@ -61,6 +95,8 @@ bridge.subscribe(async ({ id, method, params }) => { throw new Error('EXTENSION_ENTRY_INVALID') await entry.activate(context) } + if (registrationError) + throw registrationError if (manifest.contributes.commands.some(command => !commands.has(command.id))) throw new Error('EXTENSION_COMMAND_MISSING') activated = true @@ -68,9 +104,19 @@ bridge.subscribe(async ({ id, method, params }) => { else if (method === 'command') { if (!activated || !commands.has(params.command)) throw new Error('EXTENSION_COMMAND_UNAVAILABLE') - result = await commands.get(params.command)(Object.freeze({ resource: params.resource, arguments: params.arguments ?? null })) ?? null + result = await commands.get(params.command)(Object.freeze({ resource: params.resource, arguments: params.arguments ?? null, invocation: params.invocation ?? null })) ?? null + } + else if (method === 'panes') { + applyPanes(params.panes) + } + else if (method === 'interactionEnded') { + interactions.get(params.id)?.abort() + interactions.delete(params.id) } else if (method === 'deactivate') { + for (const controller of interactions.values()) controller.abort() + interactions.clear() + paneListeners.clear() activated = false try { await entry?.deactivate?.() diff --git a/apps/buddy/electron/main/extensions/runtime/view.js b/apps/buddy/electron/main/extensions/runtime/view.js index 027075ef..e9dc0228 100644 --- a/apps/buddy/electron/main/extensions/runtime/view.js +++ b/apps/buddy/electron/main/extensions/runtime/view.js @@ -13,6 +13,9 @@ let mount = null let anchor = null let control = null let overlay = false +let interactionId = null +const activationListeners = new Set() +const messageListeners = new Set() const environmentListeners = new Set() const mountListeners = new Set() const anchorListeners = new Set() @@ -47,6 +50,14 @@ addEventListener('message', (event) => { parent.postMessage({ channel: 'lexora-extension', token, pong: data.ping }, '*') return } + if (data.activation) { + for (const listener of activationListeners) listener(Object.freeze(data.activation)) + return + } + if (Object.hasOwn(data, 'message')) { + for (const listener of messageListeners) listener(data.message) + return + } if (data.workbench) { applyWorkbench(data.workbench) return @@ -91,6 +102,10 @@ addEventListener('message', (event) => { else item.reject(new Error(data.value)) }) addEventListener('keydown', (event) => { + if (interactionId && event.isTrusted && event.key === 'Escape') { + event.preventDefault() + parent.postMessage({ channel: 'lexora-extension', token, endInteraction: true }, '*') + } if (control && event.isTrusted && event.key === 'Escape') parent.postMessage({ channel: 'lexora-extension', token, dismiss: true }, '*') }) @@ -104,11 +119,12 @@ addEventListener('pagehide', () => { async function initialize() { try { const initial = await request('bootstrap') + interactionId = initial.interactionId overlay = initial.presentation === 'decoration' anchor = initial.anchor ?? anchor mount = initial.mount ?? mount control = initial.control - if (overlay || initial.presentation === 'control' || initial.location === 'mount') { + if (overlay || initial.presentation === 'control' || initial.presentation === 'slot' || initial.location === 'mount') { const style = document.createElement('style') style.textContent = 'html,body{height:100%;background:transparent}body>main{height:100%;box-sizing:border-box;padding:0}' document.head.append(style) @@ -131,6 +147,9 @@ async function initialize() { } const api = Object.freeze({ apiVersion: initial.apiVersion, + instanceId: initial.instanceId, + interaction: interactionId ? Object.freeze({ id: interactionId, setRegions: regions => request('interaction.setRegions', regions), onActivate: listener => subscribe(activationListeners, listener) }) : null, + onMessage: listener => subscribe(messageListeners, listener), get workbench() { return workbench }, onWorkbenchChange: listener => subscribe(workbenchListeners, listener), get visible() { return visible }, diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts index 42808127..fd1a0708 100644 --- a/apps/buddy/electron/preload/extensions.ts +++ b/apps/buddy/electron/preload/extensions.ts @@ -20,6 +20,10 @@ export function createExtensionApi(): ExtensionApi { devtools: id => request({ action: 'devtools', id }), revokeResources: id => request({ action: 'revokeResources', id }), execute: (id, command, resource) => request({ action: 'execute', id, command, resource }), + executeMenu: (id, menu, invocation) => request({ action: 'executeMenu', id, menu, invocation }), + executeSlash: (id, command, argumentsText, instanceId) => request({ action: 'executeSlash', id, command, arguments: argumentsText, instanceId }), + updatePanes: panes => request({ action: 'updatePanes', panes }), + endInteraction: id => request({ action: 'endInteraction', id }), openView: view => request({ action: 'openView', view }), closeView: (viewId, generation, token) => request({ action: 'closeView', viewId, generation, token }), viewRequest: (viewId, generation, token, method, params) => request({ action: 'viewRequest', viewId, generation, token, method, params }), diff --git a/apps/buddy/extensions/README.md b/apps/buddy/extensions/README.md index 3003ebc7..779799b2 100644 --- a/apps/buddy/extensions/README.md +++ b/apps/buddy/extensions/README.md @@ -32,4 +32,4 @@ API 1 不开放 Node、shell 或任意 Desktop API。文件只读,仅能访问 需要独立开发窗口时,先构建 Electron,再运行 `node extensions/tools.mjs dev /path/to/plugin`。开发窗口使用单独的临时配置;修改后重新加载构建目录并重启扩展。SDK 快照可以通过 `node extensions/tools.mjs export-sdk /path/to/plugin-sdk/sdk` 导出。 -API 2 通过 `contributes.placements` 将视图挂到输入框或侧边栏装饰、工作台顶部/底部面板,以及用户选择的思考等级控件。内容仍在隔离视图运行;宿主提供锚点几何、允许的控件值与生命周期。清单和调用示例见 [插件协议](../service/resources/skills/plugin-creator/references/protocol.md)。API 1 插件继续可用。 +API 2 通过 `contributes.placements` 提供装饰、工作台面板与思考等级控件。API 3 增加可选择的内容区、操作菜单、独立分屏挂载、带插件命名空间的输入命令与可取消交互。创作时用 `lexora_plugin_capabilities` 按需查询当前宿主支持的位置。内容仍在隔离视图运行;清单和调用示例见 [插件协议](../service/resources/skills/plugin-creator/references/protocol.md)。旧版插件继续可用。 diff --git a/apps/buddy/extensions/sdk/authoring.ts b/apps/buddy/extensions/sdk/authoring.ts index 6d35c12d..3810799d 100644 --- a/apps/buddy/extensions/sdk/authoring.ts +++ b/apps/buddy/extensions/sdk/authoring.ts @@ -1,3 +1,4 @@ export { compileExtensionSource } from '../../platform/extensions/compileExtensionSource' export { extensionIconUrl } from '../../platform/extensions/extensionIcon' export { extensionManifestSchema, extensionPathSchema } from '../../shared/extensions/extensionManifest' +export { queryWorkbenchCapabilities, workbenchCapabilityQuerySchema } from '../../shared/workbench/workbenchUi' diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index 88b8e6f5..739f1604 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -1,7 +1,8 @@ -import type { ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi' +import type { ExtensionMenuInvocation, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../shared/extensions/extensionAuthoring' import type { ExtensionResourceSelection } from '../../shared/extensions/extensionResources' import type { SpaceFileTarget } from '../../shared/spaces/spaceFileApi' +import type { WorkbenchPaneSnapshot } from '../../shared/workbench/workbenchInteraction' import type { JsonValue } from '../../shared/workbench/workbenchState' import type { ExtensionCompiler } from './compileExtensionSource' import type { ExtensionPackage, ExtensionPackageStore } from './ExtensionPackageStore' @@ -10,9 +11,11 @@ import { basename } from 'node:path' import { z } from 'zod' import { extensionError, extensionJsonSchema, extensionResourceSchema } from '../../shared/extensions/extensionApi' import { EXTENSION_CATALOG_URL } from '../../shared/extensions/extensionCatalog' +import { extensionCommandNamespace } from '../../shared/extensions/extensionCommands' import { extensionCompatible, extensionManifestSchema } from '../../shared/extensions/extensionManifest' import { extensionDirectoryScanSchema, extensionResourceSelectionSchema } from '../../shared/extensions/extensionResources' import { extensionNotificationSchema, extensionScheduleIdSchema, extensionScheduleInputSchema } from '../../shared/extensions/extensionSchedule' +import { workbenchHitRegionsSchema } from '../../shared/workbench/workbenchInteraction' import { controlProposalSchema, extensionPresentationRequestSchema } from '../../shared/workbench/workbenchUi' import { publicWebUrl, readResponseBytes } from '../network/publicWebTransport' import { ExtensionCatalogService } from './ExtensionCatalogService' @@ -47,6 +50,7 @@ interface RunningExtension { ready: Promise active: boolean inFlight: number + panesPending?: boolean } interface RunningView { input: ExtensionViewInput @@ -71,6 +75,8 @@ export class ExtensionService { readonly #views = new Map() readonly #diagnostics = new Map>() readonly #notificationTimes = new Map() + readonly #interactions = new Map() + #panes: WorkbenchPaneSnapshot[] = [] #loading: Promise | undefined #mutating = Promise.resolve() #compiling = Promise.resolve() @@ -271,17 +277,80 @@ export class ExtensionService { async execute(id: string, command: string, target: SpaceFileTarget | null): Promise { const running = await this.#activate(id) + await this.#executeCommand(running, command, target) + } + + updatePanes(panes: WorkbenchPaneSnapshot[]): void { + this.#panes = panes + for (const running of this.#running.values()) { + if (running.active) + this.#publishPanes(running) + } + } + + #publishPanes(running: RunningExtension): void { + if (running.package.manifest.apiVersion < 3 || running.panesPending || running.abort.signal.aborted) + return + const panes = this.#panes + running.panesPending = true + void running.host.call('panes', { panes }).catch(() => {}).finally(() => { + running.panesPending = false + if (panes !== this.#panes) + this.#publishPanes(running) + }) + } + + async executeSlash(id: string, command: string, argumentsText: string, instanceId?: string): Promise { + const running = await this.#activate(id) + const contribution = running.package.manifest.contributes.commands.find(item => item.id === command) + if (!contribution?.slash || contribution.hidden) + throw new Error('EXTENSION_COMMAND_UNAVAILABLE') + if (instanceId && !this.#panes.some(pane => pane.id === instanceId && pane.visible)) + throw new Error('EXTENSION_COMMAND_UNAVAILABLE') + return this.#executeCommand(running, command, null, { target: 'slash', instanceId: instanceId ?? null }, argumentsText) + } + + async endInteraction(id: string): Promise { + const interaction = this.#interactions.get(id) + if (!interaction) + return + this.#interactions.delete(id) + interaction.abort.abort() + for (const [viewId, view] of this.#views) { + if (view.input.interactionId === id) { + view.dispose() + this.#views.delete(viewId) + } + } + const { running } = interaction + const removing = this.#ports.workbench({ kind: 'interaction', requestId: randomUUID(), extensionId: running.package.manifest.id, generation: running.generation, interactionId: id, title: null }, AbortSignal.timeout(10000)) + if (!running.abort.signal.aborted) + void running.host.call('interactionEnded', { id }).catch(() => {}) + await removing + } + + async executeMenu(id: string, menuId: string, input: ExtensionMenuInvocation): Promise { + const running = await this.#activate(id) + const menu = running.package.manifest.contributes.menus.find(menu => menu.id === menuId && menu.target === input.target) + if (!menu) + throw new Error('EXTENSION_COMMAND_UNAVAILABLE') + const invocation = { target: input.target, instanceId: input.instanceId ?? null, ...(running.package.manifest.permissions.selectedContent && ['composer.actions', 'message.actions'].includes(input.target) && input.content !== undefined ? { content: input.content } : {}) } + return this.#executeCommand(running, menu.command, input.target === 'resource.actions' ? input.resource : null, invocation) + } + + async #executeCommand(running: RunningExtension, command: string, target: SpaceFileTarget | null, invocation: JsonValue = null, argumentsValue: JsonValue = null): Promise { if (!running.package.manifest.contributes.commands.some(item => item.id === command)) throw new Error('EXTENSION_COMMAND_UNAVAILABLE') let resource: ExtensionResource | null = null if (target && running.package.manifest.permissions.selectedResource === 'read') { await this.#ports.readText(target, running.abort.signal) this.#assertCurrent(running) - resource = await this.store.grant(id, target) + resource = await this.store.grant(running.package.manifest.id, target) } this.#assertCurrent(running) - await running.host.call('command', { command, resource, arguments: null }) + const result = await running.host.call('command', { command, resource, arguments: argumentsValue, invocation }) this.#assertCurrent(running) + return extensionJsonSchema.parse(result) } async openView(input: ExtensionViewInput): Promise { @@ -296,9 +365,20 @@ export class ExtensionService { throw new Error('EXTENSION_PLACEMENT_UNAVAILABLE') if (view.resource === 'selected-file' && !input.resource) throw new Error('EXTENSION_RESOURCE_REQUIRED') + if (input.interactionId) { + const interaction = this.#interactions.get(input.interactionId) + const placement = running.package.manifest.contributes.placements.find(item => item.id === input.placementId) + if (interaction?.running !== running || placement?.kind !== 'view' || !placement.interaction) + throw new Error('EXTENSION_INTERACTION_ENDED') + } + else if (running.package.manifest.contributes.placements.some(item => item.id === input.placementId && item.kind === 'view' && item.interaction)) { + throw new Error('EXTENSION_INTERACTION_REQUIRED') + } if (input.resource) await this.#resource(running, input.resource.id) this.#assertCurrent(running) + if (input.interactionId && !this.#interactions.has(input.interactionId)) + throw new Error('EXTENSION_INTERACTION_ENDED') if (this.#viewOpenings.get(input.viewId) !== request) throw new Error('EXTENSION_VIEW_EXPIRED') const old = this.#views.get(input.viewId) @@ -338,7 +418,7 @@ export class ExtensionService { const placement = view.running.package.manifest.contributes.placements.find(placement => placement.id === view.input.placementId) let result: JsonValue if (method === 'bootstrap') { - result = { apiVersion: view.running.package.manifest.apiVersion, entry: contribution.entry, location: placement?.kind === 'view' ? 'mount' : contribution.location, presentation: placement?.kind ?? (contribution.location === 'window-overlay' ? 'decoration' : 'view'), resource: view.input.resource, state: view.input.state, stateVersion: view.input.stateVersion, expectedStateVersion: contribution.stateVersion } + result = { apiVersion: view.running.package.manifest.apiVersion, instanceId: view.input.instanceId ?? null, interactionId: view.input.interactionId ?? null, interactionMode: placement?.kind === 'view' ? placement.interaction ?? null : null, entry: contribution.entry, location: placement?.kind === 'view' ? 'mount' : contribution.location, presentation: placement?.kind ?? (contribution.location === 'window-overlay' ? 'decoration' : 'view'), resource: view.input.resource, state: view.input.state, stateVersion: view.input.stateVersion, expectedStateVersion: contribution.stateVersion } } else if (method === 'view.setState') { if (contribution.location !== 'context' || (placement && placement.kind !== 'view')) @@ -363,6 +443,8 @@ export class ExtensionService { if (placement?.kind !== 'view') throw new Error('EXTENSION_METHOD_DENIED') const presentation = extensionPresentationRequestSchema.parse(params) + if (placement.interaction && ((presentation.target && !['workbench', 'workbench.pane'].includes(presentation.target)) || presentation.position === 'static')) + throw new Error('EXTENSION_METHOD_DENIED') const saved = await this.#ports.workbench({ kind: 'presentation', requestId: randomUUID(), viewId: id, generation, token, presentation }, view.running.abort.signal) if (saved !== id) throw new Error('EXTENSION_PRESENTATION_FAILED') @@ -495,11 +577,20 @@ export class ExtensionService { else { throw new Error('EXTENSION_METHOD_DENIED') } } } + else if (method === 'interaction.setRegions') { + if (placement?.kind !== 'view' || placement.interaction !== 'regions' || !view.input.interactionId || !this.#interactions.has(view.input.interactionId)) + throw new Error('EXTENSION_METHOD_DENIED') + const regions = workbenchHitRegionsSchema.parse(params) + const saved = await this.#ports.workbench({ kind: 'regions', requestId: randomUUID(), viewId: id, generation, token, regions }, view.abort.signal) + if (!saved) + throw new Error('EXTENSION_VIEW_EXPIRED') + result = null + } else if (method === 'commands.execute') { const input = z.object({ command: z.string().max(180), arguments: extensionJsonSchema }).strict().parse(params) if (!view.running.package.manifest.contributes.commands.some(item => item.id === input.command)) throw new Error('EXTENSION_COMMAND_UNAVAILABLE') - result = extensionJsonSchema.parse(await view.running.host.call('command', { ...input, resource: view.input.resource })) + result = extensionJsonSchema.parse(await view.running.host.call('command', { ...input, resource: view.input.resource, invocation: { target: 'view', instanceId: view.input.instanceId ?? null } })) } else { if (method === 'resources.readText') { @@ -543,17 +634,29 @@ export class ExtensionService { let running = this.#running.get(dependency) if (!running) { const pkg = this.store.installed[dependency]!.current + if (pkg.manifest.contributes.commands.some(command => command.slash) && [...this.#running.values()].some(other => other.package.manifest.contributes.commands.some(command => command.slash) && extensionCommandNamespace(other.package.manifest.id) === extensionCommandNamespace(dependency))) { + this.#log(dependency, 'activation.failed', 'EXTENSION_COMMAND_NAMESPACE_CONFLICT') + throw new Error('EXTENSION_COMMAND_NAMESPACE_CONFLICT') + } const generation = randomUUID() const abort = new AbortController() - const host = this.#ports.createHost(pkg, (method, params) => this.#brokerByGeneration(dependency, generation, method, params), () => this.#failed(dependency, generation)) + let host: ExtensionHost + try { + host = this.#ports.createHost(pkg, (method, params) => this.#brokerByGeneration(dependency, generation, method, params), () => this.#failed(dependency, generation)) + } + catch (error) { + this.#log(dependency, 'activation.failed', extensionError(error)) + throw error + } running = { package: pkg, generation, abort, host, ready: Promise.resolve(), active: false, inFlight: 0 } this.#running.set(dependency, running) const start = performance.now() const instance = running this.#clearError(dependency) - running.ready = host.call('activate', { manifest: pkg.manifest }).then(() => { + running.ready = host.call('activate', { manifest: pkg.manifest, panes: pkg.manifest.apiVersion >= 3 ? this.#panes : [] }).then(() => { this.#assertCurrent(instance) instance.active = true + this.#publishPanes(instance) this.#log(dependency, 'activated', undefined, Math.round(performance.now() - start)) }).catch(async (error: unknown) => { if (this.#running.get(dependency) === instance) { @@ -647,12 +750,46 @@ export class ExtensionService { const { id: resource } = z.object({ id: z.string().uuid() }).strict().parse(params) result = await this.#ports.readText(await this.#resource(running, resource), running.abort.signal) } + else if (method === 'interactions.start') { + const input = z.object({ id: z.string().uuid(), title: z.string().min(1).max(100) }).strict().parse(params) + if (running.package.manifest.apiVersion < 3 || this.#interactions.has(input.id) || [...this.#interactions.values()].filter(item => item.running === running).length >= 4) + throw new Error('EXTENSION_INTERACTION_UNAVAILABLE') + const abort = new AbortController() + this.#interactions.set(input.id, { running, title: input.title, abort }) + try { + const created = await this.#ports.workbench({ kind: 'interaction', requestId: randomUUID(), extensionId: id, generation: running.generation, interactionId: input.id, title: input.title }, AbortSignal.any([running.abort.signal, abort.signal])) + if (created !== input.id || abort.signal.aborted) + throw new Error('EXTENSION_INTERACTION_ENDED') + } + catch (error) { + await this.endInteraction(input.id).catch(() => {}) + throw error + } + result = input.id + } + else if (method === 'interactions.end') { + const input = z.object({ id: z.string().uuid() }).strict().parse(params) + const interaction = this.#interactions.get(input.id) + if (interaction && interaction.running !== running) + throw new Error('EXTENSION_METHOD_DENIED') + await this.endInteraction(input.id) + result = null + } + else if (method === 'views.broadcast') { + if (running.package.manifest.apiVersion < 3) + throw new Error('EXTENSION_METHOD_DENIED') + await this.#ports.workbench({ kind: 'message', requestId: randomUUID(), extensionId: id, generation: running.generation, message: extensionJsonSchema.parse(params) }, running.abort.signal) + result = null + } else if (method === 'placements.show' || method === 'placements.hide') { - const input = z.object({ id: z.string().max(180) }).strict().parse(params) + const input = z.object({ id: z.string().max(180), instanceId: z.string().uuid().optional(), interactionId: z.string().uuid().optional() }).strict().parse(params) const placement = running.package.manifest.contributes.placements.find(placement => placement.id === input.id) - if (placement?.kind !== 'view') + if (placement?.kind !== 'view' || (input.instanceId && placement.target !== 'workbench.pane')) throw new Error('EXTENSION_PLACEMENT_UNAVAILABLE') - result = await this.#ports.workbench({ kind: 'placement', requestId: randomUUID(), extensionId: id, generation: running.generation, placementId: placement.id, visible: method === 'placements.show' }, running.abort.signal) + const interaction = input.interactionId ? this.#interactions.get(input.interactionId) : null + if (!!placement.interaction !== !!input.interactionId || (input.interactionId && interaction?.running !== running)) + throw new Error('EXTENSION_INTERACTION_ENDED') + result = await this.#ports.workbench({ kind: 'placement', requestId: randomUUID(), extensionId: id, generation: running.generation, placementId: placement.id, visible: method === 'placements.show', ...(input.instanceId ? { instanceId: input.instanceId } : {}), ...(input.interactionId ? { interactionId: input.interactionId } : {}) }, interaction ? AbortSignal.any([running.abort.signal, interaction.abort.signal]) : running.abort.signal) if (method === 'placements.show' && !result) throw new Error('EXTENSION_VIEW_UNAVAILABLE') } @@ -731,6 +868,10 @@ export class ExtensionService { return this.#running.delete(id) running.abort.abort() + for (const [interactionId, interaction] of this.#interactions) { + if (interaction.running === running) + void this.endInteraction(interactionId).catch(() => {}) + } for (const [viewId, view] of this.#views) { if (view.running === running) { view.dispose() diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts index 08dd7e11..5498a53c 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionPackageStore.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { strToU8, zipSync } from 'fflate' import { afterEach, describe, expect, it } from 'vitest' import { extensionJsonSchema } from '../../../shared/extensions/extensionApi' -import { extensionManifestSchema } from '../../../shared/extensions/extensionManifest' +import { addedExtensionPermissions, extensionManifestSchema } from '../../../shared/extensions/extensionManifest' import { compileExtensionSource } from '../compileExtensionSource' import { readExtensionDirectory, unpackExtension } from '../extensionFiles' import { extensionActivationOrder, ExtensionPackageStore } from '../ExtensionPackageStore' @@ -31,7 +31,7 @@ describe('extension package contract', () => { }) it('rejects foreign contribution ownership, duplicate IDs, and unsupported API versions', () => { const value = manifest() - expect(extensionManifestSchema.safeParse({ ...value, apiVersion: 3 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, apiVersion: 4 }).success).toBe(false) expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, commands: [{ id: 'another.reader.open', title: 'Open' }] } }).success).toBe(false) expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, commands: [value.contributes.commands[0], value.contributes.commands[0]] } }).success).toBe(false) }) @@ -202,3 +202,58 @@ it('keeps API 1 compatible and validates UI placements and permission upgrades i } expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, views: [{ ...value.contributes.views[0], resource: 'selected-file' }] }, permissions: { ...value.permissions, selectedResource: 'read' } }).success).toBe(false) }) + +it('requires API 3 and a bounded declaration for a content slot', () => { + const value = manifest({ + apiVersion: 3, + id: 'tests.footer', + contributes: { + views: [{ id: 'tests.footer.content', title: 'Footer', entry: 'view.js', resource: 'none' }], + placements: [{ id: 'tests.footer.slot', kind: 'slot', target: 'composer.footer', view: 'tests.footer.content' }], + }, + }) + const previous = manifest({ id: 'tests.footer', apiVersion: 2, contributes: { views: [] } }) + expect(addedExtensionPermissions(previous.permissions, value.permissions)).toEqual([]) + expect(extensionManifestSchema.safeParse({ ...value, apiVersion: 2 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...value, contributes: { ...value.contributes, placements: [{ ...value.contributes.placements[0], presentation: { position: 'absolute' } }] } }).success).toBe(false) +}) + +it('validates each content target size and menu command ownership with API compatibility', () => { + const content = (target: string, height: number) => ({ id: 'tests.reader.content', kind: 'slot', target, height, view: 'tests.reader.view' }) + const base = { schemaVersion: 1, id: 'tests.reader', name: 'Regions', version: '1.0.0', apiVersion: 3, engines: { lexora: '*' }, entry: 'extension.js', contributes: { commands: [{ id: 'tests.reader.run', title: 'Run' }], views: [{ id: 'tests.reader.view', title: 'View', entry: 'view.js', resource: 'none' }] } } + const parse = (placement: unknown) => extensionManifestSchema.safeParse({ ...base, contributes: { ...base.contributes, placements: [placement] } }) + expect(parse(content('composer.footer', 64)).success).toBe(false) + expect(parse(content('composer.accessory', 64)).success).toBe(true) + expect(parse(content('task.welcome', 400)).success).toBe(true) + expect(parse(content('workbench.pane.empty', 641)).success).toBe(false) + expect(parse(content('arbitrary.selector', 64)).success).toBe(false) + const withMenu = { ...base, contributes: { ...base.contributes, menus: [{ id: 'tests.reader.menu', command: 'tests.reader.run', target: 'message.actions' }] } } + expect(extensionManifestSchema.safeParse(withMenu).success).toBe(true) + expect(extensionManifestSchema.safeParse({ ...withMenu, apiVersion: 2 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...withMenu, contributes: { ...withMenu.contributes, commands: [] } }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...base, apiVersion: 2, permissions: { selectedContent: true } }).success).toBe(false) + const previous = manifest() + const next = manifest({ apiVersion: 3, permissions: { selectedResource: 'read', selectedContent: true } }) + expect(addedExtensionPermissions(previous.permissions, next.permissions)).toEqual(['selectedContent']) +}) + +it('validates ASCII command names while allowing localized descriptions and system names inside plugin namespaces', () => { + const base = manifest({ apiVersion: 3, contributes: { commands: [{ id: 'tests.reader.run', title: 'Run', slash: { name: 'read-file' } }] } }) + const command = base.contributes.commands[0]! + for (const name of ['/read', 'Read', '读取', 'café', 'read file', 'read:1', '-read', 'read\u202E', '', 'a'.repeat(65)]) { + expect(extensionManifestSchema.safeParse({ ...base, contributes: { commands: [{ ...command, slash: { name } }] } }).success, name).toBe(false) + } + for (const name of ['read-file', 'compact', 'review', 'start-2']) + expect(extensionManifestSchema.safeParse({ ...base, contributes: { commands: [{ ...command, slash: { name } }] } }).success, name).toBe(true) + expect(extensionManifestSchema.safeParse({ ...base, apiVersion: 2 }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...base, contributes: { commands: [{ ...command, hidden: true }] } }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...base, contributes: { commands: [command, { ...command, id: 'tests.reader.other' }] } }).success).toBe(false) +}) + +it('restricts transient interactions to absolute API 3 workbench and pane mounts', () => { + const base = manifest({ apiVersion: 3, contributes: { views: [{ id: 'tests.reader.game', title: 'Game', entry: 'view.js', resource: 'none' }], placements: [{ id: 'tests.reader.layer', view: 'tests.reader.game', kind: 'view', target: 'workbench', interaction: 'regions', presentation: { position: 'absolute' } }] } }) + expect(extensionManifestSchema.safeParse({ ...base, apiVersion: 2 }).success).toBe(false) + for (const target of ['app.sidebar', 'workbench.sidebar']) + expect(extensionManifestSchema.safeParse({ ...base, contributes: { ...base.contributes, placements: [{ ...base.contributes.placements[0], target }] } }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...base, contributes: { ...base.contributes, placements: [{ ...base.contributes.placements[0], presentation: { position: 'static' } }] } }).success).toBe(false) +}) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts index 63d54916..6981a73c 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts @@ -40,7 +40,7 @@ async function fixture(overrides: Partial = {}) { createView: () => ({ token: randomUUID(), url: 'lexora-extension://fixture/__view.html', dispose: () => {} }), workbench: async (event) => { events.push(event) - return event.kind === 'state' ? event.viewId : randomUUID() + return event.kind === 'interaction' ? event.interactionId : event.kind === 'state' || event.kind === 'regions' ? event.viewId : randomUUID() }, readText: async target => `Content of ${target.path}`, get: async () => new Response('network'), @@ -204,6 +204,22 @@ it('authorizes each placement and control request against its own live view sess await expect(service.viewRequest(session.id, session.generation, session.token, 'control.propose', proposal)).rejects.toThrow('EXTENSION_VIEW_EXPIRED') }) +it('keeps a content slot limited to its selected view surface', async () => { + const { root, store, service, hosts } = await fixture() + const value = manifest({ apiVersion: 3, id: 'tests.footer', contributes: { + views: [{ id: 'tests.footer.content', title: 'Footer', entry: 'view.js', resource: 'none' }], + placements: [{ id: 'tests.footer.slot', view: 'tests.footer.content', kind: 'slot', target: 'composer.footer' }], + } }) + await service.install((await reviewPackage(root, store, value)).token) + const session = await service.openView({ viewId: randomUUID(), extensionId: value.id, viewType: 'tests.footer.content', placementId: 'tests.footer.slot', resource: null, state: null, stateVersion: 0 }) + const request = (method: string, params: JsonValue) => service.viewRequest(session.id, session.generation, session.token, method, params) + expect(await request('bootstrap', null)).toEqual(expect.objectContaining({ apiVersion: 3, presentation: 'slot' })) + await expect(request('view.setState', {})).rejects.toThrow('EXTENSION_METHOD_DENIED') + await expect(request('view.setPresentation', { height: 200 })).rejects.toThrow('EXTENSION_METHOD_DENIED') + await expect(request('control.propose', { revision: randomUUID(), value: 'high' })).rejects.toThrow('EXTENSION_METHOD_DENIED') + await expect(hosts[0]!.broker('placements.show', { id: 'tests.footer.slot' })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') +}) + it('does not let an older concurrent open replace a newer view endpoint', async () => { const { store, service } = await fixture() const resource = await store.grant('tests.reader', target) @@ -312,3 +328,129 @@ it('keeps export permission separate from reads and expires writers with their o await expect(request('resources.commitSave', { id })).rejects.toThrow('EXTENSION_VIEW_EXPIRED') expect(await readFile(destination, 'utf8')).toBe('original') }) + +it.each([false, true])('limits menu invocation to user-selected content with permission %s', async (selectedContent) => { + const { root, store, service, hosts } = await fixture() + const value = manifest({ apiVersion: 3, id: 'tests.actions', permissions: { selectedContent, selectedResource: 'read' }, contributes: { + commands: [{ id: 'tests.actions.run', title: 'Run', hidden: true }], + menus: ['composer', 'message', 'task', 'resource'].map(kind => ({ id: `tests.actions.${kind}`, command: 'tests.actions.run', target: `${kind}.actions` as 'composer.actions' | 'message.actions' | 'task.actions' | 'resource.actions' })), + views: [{ id: 'tests.actions.view', title: 'View', entry: 'view.js', resource: 'none' }], + } }) + await service.install((await reviewPackage(root, store, value)).token) + const instanceId = randomUUID() + await service.executeMenu(value.id, 'tests.actions.composer', { target: 'composer.actions', instanceId, content: 'Selected draft', resource: target }) + expect(hosts[0]!.commands.at(-1)).toEqual({ command: 'tests.actions.run', resource: null, arguments: null, invocation: { target: 'composer.actions', instanceId, ...(selectedContent ? { content: 'Selected draft' } : {}) } }) + await service.executeMenu(value.id, 'tests.actions.task', { target: 'task.actions', instanceId, content: 'Unrelated text', resource: target }) + expect(hosts[0]!.commands.at(-1)).toMatchObject({ resource: null, invocation: { target: 'task.actions', instanceId } }) + expect(JSON.stringify(hosts[0]!.commands.at(-1))).not.toContain('Unrelated text') + await service.executeMenu(value.id, 'tests.actions.resource', { target: 'resource.actions', instanceId, content: 'Unrelated text', resource: target }) + const fileCommand = hosts[0]!.commands.at(-1) as { resource: { id: string, name: string } } + expect(fileCommand.resource).toEqual({ id: expect.any(String), name: 'README.md' }) + expect(await hosts[0]!.broker('resources.readText', { id: fileCommand.resource.id })).toBe('Content of README.md') + await expect(service.executeMenu(value.id, 'tests.actions.composer', { target: 'message.actions', resource: null })).rejects.toThrow('EXTENSION_COMMAND_UNAVAILABLE') + const session = await service.openView({ viewId: randomUUID(), extensionId: value.id, instanceId, viewType: 'tests.actions.view', resource: null, state: {}, stateVersion: 1 }) + await service.viewRequest(session.id, session.generation, session.token, 'commands.execute', { command: 'tests.actions.run', arguments: null }) + expect(hosts[0]!.commands.at(-1)).toEqual({ command: 'tests.actions.run', arguments: null, resource: null, invocation: { target: 'view', instanceId } }) + await expect(service.viewRequest(session.id, session.generation, session.token, 'commands.execute', { command: 'tests.actions.run', arguments: null, invocation: { target: 'composer.actions', content: 'forged' } })).rejects.toThrow() +}) + +it('carries pane identity through scoped placement requests and rejects scope on global mounts', async () => { + const { root, store, service, hosts, events } = await fixture() + const value = manifest({ apiVersion: 3, id: 'tests.panes', contributes: { + views: [{ id: 'tests.panes.view', title: 'Pane', entry: 'view.js', resource: 'none' }], + placements: [ + { id: 'tests.panes.local', kind: 'view', target: 'workbench.pane', view: 'tests.panes.view' }, + { id: 'tests.panes.global', kind: 'view', target: 'workbench', view: 'tests.panes.view' }, + ], + } }) + await service.install((await reviewPackage(root, store, value)).token) + const instanceId = randomUUID() + const session = await service.openView({ viewId: randomUUID(), extensionId: value.id, viewType: 'tests.panes.view', instanceId, placementId: 'tests.panes.local', resource: null, state: {}, stateVersion: 1 }) + expect(await service.viewRequest(session.id, session.generation, session.token, 'bootstrap', null)).toMatchObject({ instanceId }) + await hosts[0]!.broker('placements.show', { id: 'tests.panes.local', instanceId }) + await hosts[0]!.broker('placements.hide', { id: 'tests.panes.local', instanceId }) + expect(events).toEqual([ + expect.objectContaining({ kind: 'placement', instanceId, visible: true }), + expect.objectContaining({ kind: 'placement', instanceId, visible: false }), + ]) + await expect(hosts[0]!.broker('placements.show', { id: 'tests.panes.global', instanceId })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') +}) + +async function interactionFixture() { + const f = await fixture() + const value = manifest({ apiVersion: 3, id: 'tests.game', contributes: { + commands: [{ id: 'tests.game.start', title: 'Start', slash: { name: 'game-start' } }], + views: [{ id: 'tests.game.view', title: 'Game', entry: 'view.js', resource: 'none' }], + placements: [{ id: 'tests.game.layer', kind: 'view', view: 'tests.game.view', target: 'workbench', interaction: 'regions', presentation: { position: 'absolute', width: '100%', height: '100%' } }], + } }) + await f.service.install((await reviewPackage(f.root, f.store, value)).token) + await f.service.execute(value.id, 'tests.game.start', null) + return { ...f, broker: f.hosts[0]!.broker, input: { extensionId: value.id, viewId: randomUUID(), viewType: 'tests.game.view', placementId: 'tests.game.layer', resource: null, state: {}, stateVersion: 1 } } +} + +it('passes explicit slash arguments and origin only, and rejects obsolete panes', async () => { + const f = await interactionFixture() + const pane = { id: randomUUID(), active: true, visible: true, rect: { x: 0, y: 0, width: 300, height: 400 } } + f.service.updatePanes([pane]) + await f.service.executeSlash('tests.game', 'tests.game.start', 'level=2', pane.id) + expect(f.hosts[0]!.commands.at(-1)).toEqual({ command: 'tests.game.start', resource: null, arguments: 'level=2', invocation: { target: 'slash', instanceId: pane.id } }) + f.service.updatePanes([{ ...pane, visible: false }]) + await expect(f.service.executeSlash('tests.game', 'tests.game.start', '', pane.id)).rejects.toThrow('EXTENSION_COMMAND_UNAVAILABLE') + await expect(f.service.executeSlash('tests.reader', 'tests.reader.open', '')).rejects.toThrow('EXTENSION_COMMAND_UNAVAILABLE') +}) + +it('allows the same local name in different plugin namespaces without sharing handlers', async () => { + const f = await interactionFixture() + const value = manifest({ id: 'tests.other', apiVersion: 3, contributes: { commands: [{ id: 'tests.other.start', title: 'Start', slash: { name: 'game-start' } }] } }) + await f.service.install((await reviewPackage(f.root, f.store, value)).token) + await f.service.executeSlash(value.id, 'tests.other.start', 'second') + await f.service.executeSlash('tests.game', 'tests.game.start', 'first') + expect((await f.service.list()).filter(item => item.state === 'active').map(item => item.manifest.id).sort()).toEqual(['tests.game', 'tests.other']) + expect(f.hosts[0]!.commands.at(-1)).toMatchObject({ command: 'tests.game.start', arguments: 'first' }) + expect(f.hosts[1]!.commands.at(-1)).toMatchObject({ command: 'tests.other.start', arguments: 'second' }) +}) + +it('requires an owned live interaction and immediately revokes its views on exit', async () => { + const f = await interactionFixture() + await expect(f.service.openView(f.input)).rejects.toThrow('EXTENSION_INTERACTION_REQUIRED') + const id = randomUUID() + await f.broker('interactions.start', { id, title: 'Game' }) + await f.broker('placements.show', { id: 'tests.game.layer', interactionId: id }) + const view = await f.service.openView({ ...f.input, interactionId: id }) + const request = (method: string, params: JsonValue) => f.service.viewRequest(view.id, view.generation, view.token, method, params) + expect(await request('bootstrap', null)).toMatchObject({ interactionId: id, interactionMode: 'regions' }) + await request('interaction.setRegions', [{ id: 'target', label: 'Hit target', rect: { x: 20, y: 20, width: 48, height: 48 } }]) + await expect(request('interaction.setRegions', [{ id: 'target', label: '', rect: { x: 0, y: 0, width: -1, height: 20 } }])).rejects.toThrow() + await expect(request('view.setPresentation', { target: 'app.sidebar' })).rejects.toThrow('EXTENSION_METHOD_DENIED') + await f.service.execute('tests.reader', 'tests.reader.open', null) + await expect(f.hosts[1]!.broker('interactions.end', { id })).rejects.toThrow('EXTENSION_METHOD_DENIED') + await f.service.endInteraction(id) + await expect(request('bootstrap', null)).rejects.toThrow('EXTENSION_VIEW_EXPIRED') + await expect(f.broker('placements.show', { id: 'tests.game.layer', interactionId: id })).rejects.toThrow('EXTENSION_INTERACTION_ENDED') + await expect(f.service.openView({ ...f.input, interactionId: id })).rejects.toThrow('EXTENSION_INTERACTION_ENDED') + expect(f.events).toContainEqual(expect.objectContaining({ kind: 'interaction', interactionId: id, title: null })) +}) + +it('cancels interaction endpoints when the host stops and keeps broadcasts owned', async () => { + const f = await interactionFixture() + const id = randomUUID() + await f.broker('interactions.start', { id, title: 'Game' }) + const view = await f.service.openView({ ...f.input, interactionId: id }) + await f.broker('views.broadcast', { score: 3 }) + expect(f.events).toContainEqual(expect.objectContaining({ kind: 'message', extensionId: 'tests.game', generation: view.generation, message: { score: 3 } })) + await f.service.enable('tests.game', false) + await expect(f.service.viewRequest(view.id, view.generation, view.token, 'bootstrap', null)).rejects.toThrow('EXTENSION_VIEW_EXPIRED') + expect(f.events).toContainEqual(expect.objectContaining({ kind: 'interaction', interactionId: id, title: null })) +}) + +it('rejects a duplicate plugin namespace and releases it when the original host stops', async () => { + const f = await interactionFixture() + const value = manifest({ id: 'other.game', apiVersion: 3, contributes: { commands: [{ id: 'other.game.run', title: 'Run', slash: { name: 'run' } }] } }) + await f.service.install((await reviewPackage(f.root, f.store, value)).token) + await expect(f.service.executeSlash(value.id, 'other.game.run', '')).rejects.toThrow('EXTENSION_COMMAND_NAMESPACE_CONFLICT') + expect((await f.service.list()).find(item => item.manifest.id === 'tests.game')?.state).toBe('active') + await f.service.enable('tests.game', false) + await f.service.restart(value.id) + await f.service.executeSlash(value.id, 'other.game.run', '') + expect((await f.service.list()).find(item => item.manifest.id === value.id)?.state).toBe('active') +}) diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md index 1d67984d..3ec4d581 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md +++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md @@ -19,6 +19,8 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 读取 [能力选择与组合](references/capabilities.md),将需求对应到输入来源、呈现方式、运行入口与状态归属,再确定最小清单。一个插件可以组合多种正式扩展点,也可以只提供命令或一个文件视图;现有示例不是插件类别或必须复制的模板。 +查询宿主扩展能力时,通过 `lexora_tool_search` 查找 `lexora_plugin_capabilities`。先无参数查询当前宿主的简短目录,再按 `kind` 或 `target` 获取所需能力的契约;不要把完整目录提前塞进提示词。内容区的共同规则见 [内容插槽](references/slots.md)。没有匹配位置时说明能力缺口,不借助 CSS 选择器或相近区域模拟替换。 + 先说明所选组合能完成的用户行为,再创作。文件阅读工具需要按文件打开的独立视图和各自的阅读状态;常驻信息条可能需要底部挂载、宿主查询命令与私有设置。不要因为都“有界面”就统一做导航页,也不要为了演示全部 API 给插件添加无关权限。 ## 阅读协议并创作 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts index ae0bcbe4..326b8849 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts +++ b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts @@ -24,12 +24,24 @@ export interface ScheduleApi { set: (schedule: ScheduleInput) => Promise remove: (id: string) => Promise } +export interface CommandInvocation { + readonly target: 'composer.actions' | 'task.actions' | 'resource.actions' | 'message.actions' | 'view' | 'slash' + readonly instanceId: string | null + readonly content?: string +} +export interface Rect { readonly x: number, readonly y: number, readonly width: number, readonly height: number } +export interface PaneSnapshot { readonly id: string, readonly active: boolean, readonly visible: boolean, readonly rect: Rect } +export interface Interaction { readonly id: string, readonly signal: AbortSignal, end: () => Promise } +export interface PlacementOptions { instanceId?: string, interactionId?: string } +export interface HitRegion { id: string, label: string, rect: Rect } export interface ExtensionContext { - readonly extension: { readonly id: string, readonly version: string, readonly apiVersion: 1 | 2 } + readonly extension: { readonly id: string, readonly version: string, readonly apiVersion: 1 | 2 | 3 } readonly subscriptions: { add: (disposable: T) => T } - readonly commands: { register: (id: string, execute: (context: { resource: Resource | null, arguments: Json }) => Json | void | Promise) => Disposable } - readonly placements: { show: (id: string) => Promise, hide: (id: string) => Promise } - readonly views: { open: (type: string, options?: { resource?: Resource | null, state?: Json }) => Promise } + readonly commands: { register: (id: string, execute: (context: { resource: Resource | null, arguments: Json, invocation: CommandInvocation | null }) => Json | void | Promise) => Disposable } + readonly placements: { show: (id: string, options?: string | PlacementOptions) => Promise, hide: (id: string, options?: string | PlacementOptions) => Promise } + readonly workbench: { readonly panes: readonly PaneSnapshot[], onPanesChange: (listener: (panes: readonly PaneSnapshot[]) => void) => Disposable } + readonly interactions: { start: (title: string) => Promise } + readonly views: { broadcast: (message: Json) => Promise, open: (type: string, options?: { resource?: Resource | null, state?: Json }) => Promise } readonly resources: ResourceApi readonly network: NetworkApi readonly storage: { get: () => Promise, set: (value: Json) => Promise } @@ -48,7 +60,7 @@ export interface ControlSnapshot { readonly options: readonly { readonly value: string, readonly label: string }[] readonly disabled: boolean } -export type MountTarget = 'workbench' | 'app.sidebar' | 'workbench.sidebar' +export type MountTarget = 'workbench' | 'app.sidebar' | 'workbench.sidebar' | 'workbench.pane' export type MountLength = number | `${number}%` | null export interface ViewPresentation { target?: MountTarget @@ -63,6 +75,7 @@ export interface ViewPresentation { left?: MountLength } export interface MountGeometry { + readonly instanceId?: string readonly target: MountTarget readonly visible: boolean readonly width: number @@ -76,6 +89,13 @@ export interface WorkbenchContextSnapshot { readonly pages: readonly { readonly id: string, readonly title: string }[] } export interface ViewContext { + readonly interaction: { + readonly id: string + setRegions: (regions: readonly HitRegion[]) => Promise + onActivate: (listener: (event: { readonly id: string, readonly x: number, readonly y: number }) => void) => Disposable + } | null + onMessage: (listener: (message: Json) => void) => Disposable + readonly instanceId: string | null readonly workbench: WorkbenchContextSnapshot onWorkbenchChange: (listener: (snapshot: WorkbenchContextSnapshot) => void) => Disposable readonly visible: boolean @@ -91,7 +111,7 @@ export interface ViewContext { } | null readonly environment: { language: string, colorScheme: 'light' | 'dark', colors: Record } onEnvironmentChange: (listener: (environment: ViewContext['environment']) => void) => Disposable - readonly apiVersion: 1 | 2 + readonly apiVersion: 1 | 2 | 3 readonly resource: Resource | null readonly state: Json readonly stateVersion: number diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md index fa88c89b..6fb80f6d 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md @@ -5,9 +5,13 @@ | 用户需要 | 清单与入口 | 状态和边界 | | --- | --- | --- | | 对当前文件提供阅读、统计或专用展示 | `selectedResource: "read"`;`resource: "selected-file"` 的视图;宿主可见命令将收到的 `resource` 传给 `views.open`,视图用 `resources.readText(context.resource)` 读取 | 用户先打开文件,再执行插件命令。句柄不包含任意路径,每次打开是独立实例;不能枚举目录、读其他视图的文件或写回文件 | +| 输入 `/插件名:命令` 执行插件动作 | API 3 命令声明 `slash`,并在 `activate` 注册处理函数;见 [注册命令](commands.md) | 精确匹配,包内校验注册,跨插件命名空间隔离,不请求模型 | +| 需要真实分屏布局或临时覆盖交互 | API 3;见 [交互会话](interactions.md) | 订阅布局;稀疏点击区域或独占交互;宿主提供独立退出入口 | | 执行一次动作,无需常驻页面 | 宿主 `activate` 注册 `contributes.commands` 中的命令 | 可见命令进入命令面板和已安装插件卡片的“运行命令”菜单;命令收到的 `resource` 可能为空。只供自己视图调用的命令设为 `hidden: true` | | 目录入口、较完整的页面或设置表单 | `contributes.navigation` 引用 `resource: "none"` 的视图 | API 2 可省略视图 `location`;页面由 `render` 绘制。需要持久配置时通过自身隐藏命令调用宿主私有存储 | -| 将自定义内容挂到页面区域 | `kind: "view"`、`target` 与 `presentation`;宿主命令调用 `placements.show/hide` | 区域包括 `workbench`、`app.sidebar`、`workbench.sidebar`。插件渲染隔离内容,通过通用外框布局接口声明定位和尺寸。挂载口没有面板 UI 或交互 | +| 将自定义内容挂到页面区域 | `kind: "view"`、`target` 与 `presentation`;宿主命令调用 `placements.show/hide` | 通过能力查询选择全局、页面或分屏目标。插件渲染隔离内容,通过通用外框布局接口声明定位和尺寸。挂载口没有面板 UI 或交互 | +| 在业务区域执行操作 | `contributes.menus` 引用已声明命令;用能力查询选目标 | 输入、消息、文件或任务操作共享一套协议;仅点击时按权限提供所选内容,分屏标识固定到发起位置 | +| 替换或追加宿主内容 | `kind: "slot"`;按需查 [内容插槽目录](slots.md) | 按目录的单选或多选策略由用户选择提供者;不可用时回退。位置和外框归宿主,插件只绘制自己的内容 | | 限定生效页面或响应页面变化 | 贡献的 `when`;视图的 `workbench/onWorkbenchChange` 与 `visible/onVisibilityChange` | 按稳定页面 ID 与公开上下文匹配;离开范围隐藏而保留实例,业务暂停或继续由插件决定。未知上下文不匹配,不读取父页面 DOM | | 自定义浮动内容或运行时换区域 | `presentation.position: "absolute"`;`setPresentation` 更新目标及外框 CSS 布局;`mount/onMountChange` 获取局部几何 | 拖动、折叠、恢复入口等由插件自行实现。宿主不理解这些行为,只维持独立实例、投影和边界;换位置保留会话,关闭销毁 | | 用户选择的文件或目录 | `localResources: true`;交互视图的 `resources.pickFiles/pickDirectory` | 返回不透明句柄;任意文件格式都可读取。以 `scanDirectory` 按需筛选授权目录,`readText/readBytes/getUrl` 分别用于文本、分段二进制和受控 URL;只保存 ID,URL 按视图重新获取 | @@ -23,7 +27,7 @@ ## 状态与模块 - `activate(context)` 管理命令、私有设置、通知和定时任务;`render(context, container)` 管理自己的隔离 UI。只需要视图时不创建空宿主;需要命令时必须同时声明宿主入口和命令 ID。 -- 资源页签及普通挂载面板用 `ViewContext.state/setState` 保存该实例的筛选、阅读位置等 JSON。升级比较 `stateVersion` 与 `expectedStateVersion`,转换后再保存。导航页面、装饰和替换控件不支持这条持久化路径。 +- 资源页签及普通挂载面板用 `ViewContext.state/setState` 保存该实例的筛选、阅读位置等 JSON。升级比较 `stateVersion` 与 `expectedStateVersion`,转换后再保存。导航页面、装饰、内容插槽和替换控件不支持这条持久化路径。 - 挂载目标和外框布局通过 `setPresentation` 更新;内容样式、交互及业务状态完全由插件实现,业务状态使用 `setState` 或宿主私有存储。宿主不会依据插件数量提供面板切换或自动避让。不能用父页面 CSS 选择器或 Vue Teleport 穿透隔离。 - 扩展级配置用宿主 `storage.get/set`。视图通过自身命令读写,不访问宿主内存;更改数据结构时增加 `dataVersion` 并提供 `migrate`。不要把一个文件的阅读位置存成覆盖所有文件的全局值。 - 每个视图具有独立 JS 环境。没有通用跨视图广播;需要设置同步时设计明确刷新入口或有界的读取周期,并在销毁时取消,不能承诺不存在的订阅事件。源码中的纯函数可通过包内相对导入复用。 @@ -33,4 +37,4 @@ 验收从用户行为出发,不以采用某个样例的文件名或界面作为成功依据。例如文件阅读工具应验证两份不同文件、空内容、各自筛选状态及重开恢复;网络信息条应验证允许来源、请求失败后的状态、关闭与再次显示;自定义控件应验证当前快照、禁用或隐藏时提交被拒绝,以及故障后内置控件可用。 -对每项需求明确已有 API 是否覆盖。没有任意插槽、父页面 DOM、静默覆写用户文件、通用 IPC 或 Agent 调用权限;核心要求超出已开放能力时先明确缺口与可实现范围,不能把样例资源或相近布局当作已经满足核心要求,也不用内部接口模拟完整实现。 +对每项需求明确已有 API 是否覆盖。只有当前宿主能力查询返回的正式位置,没有任意 DOM 插槽、父页面 DOM、静默覆写用户文件、通用 IPC 或 Agent 调用权限;核心要求超出已开放能力时先明确缺口与可实现范围,不能把样例资源或相近布局当作已经满足核心要求,也不用内部接口模拟完整实现。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/commands.md b/apps/buddy/service/resources/skills/plugin-creator/references/commands.md new file mode 100644 index 00000000..5a24ad64 --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/commands.md @@ -0,0 +1,30 @@ +# 注册命令 + +API 3 可将既有命令公开为输入框 `/插件名:命令`。先声明,再在 `activate` 中通过 `context.commands.register(id, handler)` 注册;命令面板、菜单、视图调用与 Slash 共用同一处理函数。 + +```json +{ "id": "local.example.start", "title": "开始", "slash": { "name": "start", "description": "开始一次交互" } } +``` + +```ts +export function activate(context: ExtensionContext) { + context.commands.register('local.example.start', async ({ arguments: args, invocation }) => { + if (invocation?.target === 'slash') { + // args 是用户显式输入的参数字符串,instanceId 是发起分屏。 + await start(args as string, invocation.instanceId) + } + }) +} +``` + +名称是插件内的本地名称,宿主使用插件 ID 中发布者后面的名称作为命名空间,例如 `local.example` 的命令为 `/example:start`。中文显示名称不参与匹配。顶级 `/review`、`/compact`、`/skills`、`/status` 等只归系统所有;插件可声明本地 `review`,但只能通过 `/example:review` 调用,不生成顶级短别名。 + +本地命令名长度为 1–64,匹配 `[a-z][a-z0-9-]*`:英文小写字母开头,后续允许数字和连字符。`title` 和 `description` 用于中文说明。包内本地名称唯一,隐藏命令不能声明 Slash;非法清单在编译或安装时拒绝,不自动修正或改名。不同插件命名空间内可使用同一命令名;如果两个发布者使用相同插件名并都提供 Slash,后启动的插件报命名空间冲突,需修改插件名。 + +每个声明必须在 `activate` 完成前注册一次。未知 ID、重复注册、非函数处理器或缺少注册都会使该插件启动失败;捕获注册错误也不能使错误插件启动。修正后显式重启。 + +候选按前缀显示;选中只插入命令,确认发送才执行。执行严格匹配完整名称和大小写,参数保持为字符串,不自动解析引号、JSON 或自然语言。插件本地命令不依赖模型配置,可以在任务流式输出时执行。只传命令参数与发起分屏,不传输入框附件、引用、会话身份或历史;带这些内容时拒绝执行并保留草稿。命令不可用或失败时也保留草稿,不降级为模型提示。 + +`invocation.target` 为 `slash`,`resource` 为 null,`arguments` 为最多 8192 字符的参数字符串。命令面板调用仍提供既有资源语义且参数为空;视图调用的参数仍为 JSON。插件应按实际调用入口验证自己的参数。 + +停止覆盖交互不能只依赖 Slash;参见 [交互会话](interactions.md) 的宿主退出机制。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/interactions.md b/apps/buddy/service/resources/skills/plugin-creator/references/interactions.md new file mode 100644 index 00000000..16096c1d --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/interactions.md @@ -0,0 +1,51 @@ +# 分屏与临时交互 + +API 3 提供真实布局快照和可取消的交互会话。先查询 `lexora_plugin_capabilities({kind:"runtime"})`,类型以 [api.d.ts](api.d.ts) 为准。 + +## 分屏快照 + +宿主入口通过 `context.workbench.panes` 获取当前快照,并用 `onPanesChange` 订阅分割、拖动、关闭、页面切换和可见性变化。每个分屏只有不透明 `id`、`active`、`visible`、`rect`;坐标单位是 CSS 像素,相对 `workbench` 挂载口。不可见分屏的矩形归零,订阅可能合并中间更新。 + +不要读取宿主持久化文件、探测父 DOM、发送心跳或按宽度横排推测布局。只对可见分屏计算几何;用户切换页面或窗口隐藏时,暂停交互计时由插件负责。分屏 ID 只在当前布局中有效。 + +## 会话和退出 + +临时覆盖层在 `kind:"view"` 的 placement 声明 `interaction:"regions"` 或 `"exclusive"`,目标限定为 `workbench` / `workbench.pane`,必须为绝对定位。打开前创建会话: + +```ts +const interaction = await context.interactions.start('互动游戏') +interaction.signal.addEventListener('abort', cleanup, { once: true }) +try { + await context.placements.show('local.example.game', { + interactionId: interaction.id, + instanceId: originPaneId, + }) +} +catch (error) { + await interaction.end() + throw error +} +``` + +`instanceId` 仅适用于分屏挂载,省略时选择当前活动分屏;全工作台挂载省略该字段。同一会话可拥有多个视图。每个插件同时最多 4 个会话;会话不恢复到下一次启动。 + +宿主在覆盖区域外提供结束按钮,Esc 也可结束交互;无需重新输入命令。结束会立即撤回会话视图,再通知 `signal` 取消;即使插件清理失败,宿主仍能移除覆盖。禁用、崩溃或窗口文档替换都会回收会话。原有任务继续执行,结束交互不会停止模型任务。结束后的 ID 不能再次打开视图。 + +## 局部点击区域 + +`regions` 模式的 iframe 仅绘制。透明空白不会挡住下方的输入框、滚动或选择;只有显式声明的区域接收点击。不要依赖 iframe 内普通 DOM 监听器,使用: + +```ts +await context.interaction!.setRegions([ + { id: 'target', label: '击中目标', rect: { x: 20, y: 30, width: 64, height: 64 } }, +]) +context.interaction!.onActivate(({ id, x, y }) => hit(id, x, y)) +``` + +区域坐标相对当前视图,最多 64 个,ID 唯一、标签非空;裁剪与视图边界一致。宿主将鼠标、触摸点击或键盘按钮激活转发为统一事件;不提供全局键盘监听或任意事件透传。更新位置、大小或移除目标时同步更新区域;传 `[]` 暂停全部交互。 + +`exclusive` 模式允许 iframe 内完整交互,覆盖区域会阻挡下方操作,但仍保留宿主退出入口。普通持久挂载不需要会话。 + +## 多视图状态 + +宿主入口可用 `context.views.broadcast(message)` 向本插件当前视图广播有限 JSON,视图通过 `onMessage` 接收。广播不是持久化,也不保证新打开的视图收到历史消息;新视图应通过自己的命令拉取初始状态。布局订阅和会话状态由宿主入口统一维护,视图只绘制。涉及计时器、音频和事件监听时,绑定会话的 `signal` 或视图的 `signal` 清理。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md index b930b1a6..f4752bfc 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -1,4 +1,4 @@ -# Lexora 插件 API 2 +# Lexora 插件协议 一个包包含根 `extension.json` 和包内文件。源码包支持自包含 `.ts`、`.mts`、`.js`、`.mjs`,以及静态 CSS、图片等资源;原始 HTML、Vue 单文件组件、npm 裸导入不属于当前编译协议。第三方库需作者预先打包。不要生成 `package.json`、锁文件、构建脚本、`node_modules` 或远程 CDN 依赖。 @@ -27,9 +27,10 @@ | 字段 | 结构 | | --- | --- | -| `commands` | 数组,每项必填 `id`、`title`;`hidden` 可选,默认 false。存在命令时必须设置清单顶层的宿主 `entry` | +| `commands` | 数组,每项必填 `id`、`title`;`hidden` 可选,默认 false;API 3 可声明 `slash: {name, description?}`,规则见 [注册命令](commands.md)。存在命令时必须设置清单顶层的宿主 `entry` | +| `menus` | API 3;数组,每项必填 `id`、`command`、`target`,可选 `order`(-1000 至 1000,默认 0)、`when`;引用自己的已声明命令 | | `views` | 数组,每项必填 `id`、`title`、`entry`;`resource` 为 `selected-file`(默认)或 `none`;`location` 默认 `context`;`stateVersion` 为正整数,默认 1 | -| `placements` | 数组,每项必填 `id`、`view`、`kind`,并按 kind 添加后文规定的 `anchor`、`target` 和 `presentation`;不要把它们写入视图定义 | +| `placements` | 数组,每项必填 `id`、`view`、`kind`,并按 kind 添加后文规定的 `anchor`、`target` 或 `presentation`;不要把它们写入视图定义 | | `navigation` | 单个对象,必填 `title`、`view`,引用自己的无资源视图 | 插件 ID 使用小写字母开头的两段名称,中间以 `.` 分隔,每段可含小写字母、数字与 `-`;贡献 ID 在插件 ID 后追加由小写字母、数字、`.`、`-` 组成的后缀。声明文件路径使用包内相对路径,`.ts` 文件名直接指向源码,编译工具负责产出运行文件。 @@ -42,13 +43,13 @@ | `context` | `selected-file` 或 `none` | 工作区资源页签,由用户打开 | | `window-overlay` | `none` | 启用后自动挂载的透明窗口效果,每插件最多一个;无法点击或聚焦 | -页面与小视图由插件绘制,均使用 `render(context, container)`,共用隔离和通信 API。API 2 将视图内容与挂载位置分开,通过 `contributes.placements` 选择正式扩展点;`page` 和 `window-overlay` 在 API 1、API 2 中均可使用。插件不能查询或修改宿主 DOM。 +页面与小视图由插件绘制,均使用 `render(context, container)`,共用隔离和通信 API。API 2 起将视图内容与挂载位置分开,通过 `contributes.placements` 选择正式扩展点;`page` 和 `window-overlay` 继续可用。插件不能查询或修改宿主 DOM。 装饰、替换控件和普通挂载面板的 `container` 默认填满隔离视图,无内边距,支持子元素使用百分比高度。装饰的绘制范围仍受锚点及祖先裁剪约束;页面保持普通文档流,可按内容滚动。 导航入口位于自动化下方,声明 `contributes.navigation: { "title": "插件名称", "view": "local.plugin.settings" }`,引用一个 `resource: "none"` 的视图(API 1 仍需 `location: "page"`)。不需要设置页的效果插件可以没有导航。 -导航页面、装饰和替换控件不能使用 `context.setState()`;需要持久设置时声明宿主入口和隐藏命令,以 `context.storage.get/set` 保存。多个视图使用同一设置时明确刷新时机,插件局部开关必须能在不重载视图的情况下关闭并重新启用。资源页签的 `setState` 用于保存该页签的 JSON 状态。 +导航页面、装饰、内容插槽和替换控件不能使用 `context.setState()`;需要持久设置时声明宿主入口和隐藏命令,以 `context.storage.get/set` 保存。多个视图使用同一设置时明确刷新时机,插件局部开关必须能在不重载视图的情况下关闭并重新启用。资源页签的 `setState` 用于保存该页签的 JSON 状态。 ## 权限 @@ -58,7 +59,8 @@ | `controls: ["model.reasoning"]` | 提供可由用户选用的思考等级控件 | | `notifications: true` | 宿主 `context.notifications.show({title,body})` | | `schedules: true` | 宿主持久间隔任务,需要宿主入口与已声明命令 | -| `selectedResource: "read"` | 读取明确分配给视图的文件资源 | +| `selectedResource: "read"` | 读取用户选择并明确分配的文件资源 | +| `selectedContent: true` | API 3;用户点击插件操作时获取当前草稿或该条消息文本,不订阅输入或读取对话历史 | | `localResources: true` | 用户通过系统窗口选择文件或目录,视图读取文本、二进制或受控 URL | | `resourceExport: true` | 用户通过系统另存为窗口确认目标后,保存插件提供的字节 | | `network: ["https://example.com"]` | 通过宿主向列出的 HTTPS origin 发 GET 请求 | @@ -90,14 +92,15 @@ | kind | 必填字段 | 行为 | | --- | --- | --- | | `decoration` | `anchor` | 自动挂载至 `app.sidebar`、`workbench.sidebar`、`workbench.pane` 或 `composer.input`;每个锚点实例分别挂载,只在该区域内绘制,不接收指针和焦点 | -| `view` | `target` | `workbench`、`app.sidebar` 或 `workbench.sidebar`;`presentation` 描述区域内布局;宿主入口调用 `context.placements.show(id)` / `hide(id)` 显示或关闭 | -| `control` | `target` | 当前支持 `model.reasoning`;用户在控件样式选择中启用后才替换内置控件 | +| `view` | `target` | 用 `lexora_plugin_capabilities({kind:"mount"})` 查询目标;`presentation` 描述区域内布局,宿主入口调用 `context.placements.show(id, instanceId?)` / `hide(id, instanceId?)` | +| `control` | `target` | 当前支持 `model.reasoning`;用户在插件管理页选择后才替换内置控件 | +| `slot` | `target` | 需要将 `apiVersion` 设为 3;用户在插件管理页选择后替换目标内容,具体目标和尺寸按需查 [内容插槽目录](slots.md) | -挂载口只提供容器,不提供面板外壳或交互。声明示意:`{id,view,kind:"view",target:"workbench",presentation:{position:"absolute",width:420,height:180,right:16,bottom:16}}`。三个目标分别属于持续存在的工作台、应用侧栏和任务侧栏。挂载口暂时不可用时保留实例,恢复后继续投影。插件自行渲染全部内容、样式与交互;插件之间保留独立实例,宿主不生成切换器、不自动避让或折叠。 +挂载口只提供容器,不提供面板外壳或交互。声明示意:`{id,view,kind:"view",target:"workbench",presentation:{position:"absolute",width:420,height:180,right:16,bottom:16}}`。目标所属范围由能力目录返回。API 3 的分屏挂载以 `instanceId` 区分实例;从命令 `invocation.instanceId` 传入发起操作的分屏,不要在异步完成后重新选择焦点。省略时使用调用时的活动分屏,标识失效则拒绝打开。每个分屏分别保存状态,分屏移除时结束该实例。挂载口暂时不可用时保留实例,恢复后继续投影。插件自行渲染全部内容、样式与交互;插件之间保留独立实例,宿主不生成切换器、不自动避让或折叠。 `presentation` 是隔离视图外框的布局声明:`position` 为 `static`(默认,正常布局)或 `absolute`(相对挂载口);`order` 为 -1000 至 1000(默认 1,与核心内容的 0 比较);`zIndex` 为 0–20 的扩展层内顺序,不覆盖核心弹窗。`width/height/top/right/bottom/left` 使用 0–8192 的像素数或 0%–100% 字符串;`null` 清除该 CSS 值。未指定尺寸时宽度 100%、高度 56px,最大尺寸及可见内容受目标容器边界约束。内容内部自由使用 DOM/CSS、Canvas 等;不访问父页面 DOM。 -`context.setPresentation({...})` 合并更新上述字段,传 `target` 可转移挂载口。实例、会话与状态保留;插件的定时器、媒体等不会因为换位置而重建。布局值由宿主持久化,业务状态由插件自己保存。`context.mount` / `onMountChange` 提供目标容器宽高、可见性和当前视图的局部矩形,供插件响应尺寸变化或实现拖动等交互,不包含屏幕坐标、其他插件信息或父 DOM。 +`context.setPresentation({...})` 合并更新上述字段,传 `target` 可转移挂载口。进入分屏区域要求该视图在创建时已绑定分屏,不会把全局实例隐式移入当前焦点分屏。实例、会话与状态保留;插件的定时器、媒体等不会因为换位置而重建。布局值由宿主持久化,业务状态由插件自己保存。`context.mount` / `onMountChange` 提供目标容器宽高、可见性和当前视图的局部矩形,供插件响应尺寸变化或实现拖动等交互,不包含屏幕坐标、其他插件信息或父 DOM。 宿主不认识“折叠”“展开”“贴边”或“播放器”。需要这些效果时,插件自己渲染触发入口、控制布局并保存自己的状态。缩小视图外框不销毁内容;调用宿主命令中的 `placements.hide` 才关闭实例。插件负责再次打开的入口,可以使用自己的可见 UI、已声明命令或导航。不要依赖宿主替插件提供关闭栏或恢复按钮。 @@ -109,11 +112,19 @@ 组合视图可用导航页管理设置、挂载面板展示持续内容、隐藏命令连接私有存储。设置更新后的刷新时机由插件明确处理,页面之间没有隐式共享 JS 状态。 +## 操作菜单 + +API 3 的 `contributes.menus` 将已有命令放进业务区域的「更多操作」。先用 `lexora_plugin_capabilities({kind:"menu"})` 查询目标与输入契约,再声明 `{id,command,target,order?,when?}`。`hidden` 仅隐藏命令面板与管理卡片入口,不隐藏显式声明的菜单。 + +命令参数中的 `invocation` 包含 `target`、`instanceId`,仅在声明 `selectedContent` 且用户点击对应操作后附带 `content`。文件菜单通过原有 `selectedResource` 权限提供 `resource` 句柄。视图调用自己的命令时 `invocation.target` 为 `view`,只附带该视图的实例标识;命令面板和定时调用的 `invocation` 为空。 + +输入操作可返回 `{insertText:string}`。宿主在原草稿仍可编辑且内容未改变时,将结果作为纯文本插入原选择位置;更换草稿、编辑内容或关闭视图后忽略迟到结果,不自动发送。其他菜单忽略命令返回值。`when` 支持宿主公开上下文,消息菜单额外提供 `message.role`,文件菜单提供当前 `resource.scheme`;不能用条件读取未公开的数据。 + ## 页面上下文与生效条件 视图通过 `context.workbench` 读取只读快照:`values` 是公开语义上下文,`pages` 列出已登记页面的稳定 `id/title`。`onWorkbenchChange` 订阅变化。当前 `page.id` 使用 `lexora.tasks`、`lexora.automations`、`lexora.extensions`、`lexora.settings` 或 `extension:<插件ID>`;自动化和设置通过 `page.section` 提供子页标识,如 `plans`、`history`、`general`、`models`。其他页面不保留上一个页面的 section。未来页面通过登记扩展,不以名称、URL 或 DOM 选择器识别。这里不提供任务正文、输入内容、文件路径、路由参数或 Provider/模型身份。 -`contributes.views`、`placements`、`commands` 和 `navigation` 可声明可选的 `when`。例如 `"when": {"page.id": ["lexora.tasks", "lexora.automations"]}`;对象中的不同 key 为 AND,数组内为 OR,标量严格相等,缺失 key 不匹配。空对象或省略表示不限定,不执行表达式代码。最多 32 个 key,每个数组最多 32 项,值限字符串(256 字符)、布尔和有限数字。视图定义与挂载的条件同时满足才显示。只限定某个挂载实例时优先在 placement 声明;view 的条件影响该定义的所有实例,navigation 的条件只限定入口。 +`contributes.views`、`placements`、`commands`、`menus` 和 `navigation` 可声明可选的 `when`。例如 `"when": {"page.id": ["lexora.tasks", "lexora.automations"]}`;对象中的不同 key 为 AND,数组内为 OR,标量严格相等,缺失 key 不匹配。空对象或省略表示不限定,不执行表达式代码。最多 32 个 key,每个数组最多 32 项,值限字符串(256 字符)、布尔和有限数字。视图定义与挂载的条件同时满足才显示。只限定某个挂载实例时优先在 placement 声明;view 的条件影响该定义的所有实例,navigation 的条件只限定入口。 条件只控制 UI 的可用性,不授予权限、不启动插件,也不阻止插件自身调用命令或后台调度。命令菜单与导航入口随上下文更新。首次不满足条件的视图不激活;已打开视图离开范围时隐藏,挂载面板退出布局占位,实例、状态与会话保留,返回后恢复。控件不匹配时使用内置控件并拒绝旧提交。`placements.hide` 仍表示关闭实例,与条件隐藏不同。 @@ -174,3 +185,5 @@ context.subscriptions.add(context.commands.register('local.example.open', async ## 主题与样式 直接创建元素、设置样式,或引入包内 CSS:`link.href = new URL('./style.css', import.meta.url).href`。使用 `--lexora-background`、`--lexora-text`、`--lexora-muted`、`--lexora-border`、`--lexora-accent`、`--lexora-accent-solid`。`context.environment` 提供语言、配色与颜色;`context.onEnvironmentChange` 订阅切换。效果应柔和短暂,不遮挡正文或确认操作。 + +临时覆盖层、分屏布局订阅与局部点击区域见 [交互会话](interactions.md)。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/sdk-workflow.md b/apps/buddy/service/resources/skills/plugin-creator/references/sdk-workflow.md index 9c2716d3..a6933bcc 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/sdk-workflow.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/sdk-workflow.md @@ -15,3 +15,5 @@ pnpm run package:source <插件仓库> 安装包在 SDK 的 `.output/packages/`。开发预览可使用 `pnpm dev <插件仓库> <插件名>`,采用独立临时配置。打包会更新插件仓库的 `catalog/v1/index.json`,不会上传。运行验证仍需真实 Lexora 窗口;普通浏览器打开文件不能代替插件隔离环境验收。 在 Lexora 的「插件 → 安装插件包」选择输出包并确认权限。只有用户要求发布时才同步远端。 + +离线开发可从 SDK 的 `authoring.mjs` 导入 `queryWorkbenchCapabilities` 查询该 SDK 快照支持的目标;它不代表用户已安装宿主的版本。应用内优先查询 `lexora_plugin_capabilities`。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/slots.md b/apps/buddy/service/resources/skills/plugin-creator/references/slots.md new file mode 100644 index 00000000..8322a44c --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/slots.md @@ -0,0 +1,9 @@ +# 内容插槽 + +内容插槽由宿主负责位置和布局,插件提供隔离视图。通过 `lexora_plugin_capabilities({kind:"slot"})` 查询当前宿主支持的位置,也可指定 `target` 只读取一个位置。目录返回用途、作用范围、单选或多选策略,以及高度的最小值、最大值和默认值;不要根据 CSS 类名推测目标。 + +清单使用 API 3,声明 `{id,kind:"slot",target,view,height?}`。`view` 指向本插件 `resource:"none"` 的视图。内容插槽不接受 `presentation`,不通过 `placements.show/hide` 开关,本身不要求权限。 + +安装后由用户在插件管理的「界面呈现」中选择;单选目标替换内置内容,多选目标按用户选择顺序追加内容。未选、尚未就绪或提供者不可用时显示内置回退;没有内置内容的追加区为空。输入辅助区总高度受宿主限制,超出后滚动。 + +每个可见宿主位置拥有独立视图,`context.instanceId` 是所在分屏的不透明标识,可能为空。它可以关联自己发起的命令和分屏挂载,不提供任务身份、历史或输入文字。视图销毁时清理资源;临时内容不使用 `setState`,需要共享业务状态时通过自身宿主命令访问私有存储。 diff --git a/apps/buddy/service/src/chat/ChatCommandService.ts b/apps/buddy/service/src/chat/ChatCommandService.ts index c8b0a8e6..6ccde700 100644 --- a/apps/buddy/service/src/chat/ChatCommandService.ts +++ b/apps/buddy/service/src/chat/ChatCommandService.ts @@ -63,6 +63,7 @@ export class ChatCommandService { || draft.content.quotes?.length || directives.length !== 1 || directives[0]?.commandMode !== 'action' + || directives[0].commandId || directives[0].value !== `/${command.name}` ) { throw new BuddyServiceError('VALIDATION_FAILED') diff --git a/apps/buddy/service/src/chat/ChatTurnService.ts b/apps/buddy/service/src/chat/ChatTurnService.ts index e73b8a26..b06d3c41 100644 --- a/apps/buddy/service/src/chat/ChatTurnService.ts +++ b/apps/buddy/service/src/chat/ChatTurnService.ts @@ -670,6 +670,8 @@ async function materializeComposerDirectives( skills: Pick, ) { const directives = content.body.flatMap(paragraph => paragraph.content.filter(node => node.type === 'prompt_directive')) + if (directives.some(node => node.directive === 'slash_command' && node.commandId)) + throw new BuddyServiceError('VALIDATION_FAILED') const textCommand = parseBuddyChatCommand(buddyUserContentToText(content)) if (textCommand && isBuddyRunChatCommand(textCommand.name)) throw new BuddyServiceError('VALIDATION_FAILED') diff --git a/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts b/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts index 63b57a7f..d6c24b06 100644 --- a/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts +++ b/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts @@ -9,11 +9,17 @@ import { Type } from 'typebox' import { Check } from 'typebox/value' import { readExtensionDirectory } from '../../../platform/extensions/extensionFiles' import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' -import { EXTENSION_BUILD_RPC, EXTENSION_INSPECT_RPC, EXTENSION_REVIEW_REQUEST, extensionBuildResultSchema, extensionInspectionSchema } from '../../../shared/extensions/extensionAuthoring' +import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_INSPECT_RPC, EXTENSION_REVIEW_REQUEST, extensionBuildResultSchema, extensionCapabilitiesSchema, extensionInspectionSchema } from '../../../shared/extensions/extensionAuthoring' +import { workbenchCapabilityKinds } from '../../../shared/workbench/workbenchContributionCatalog' import { resolveGrantedPath } from '../directories/resolveGrantedPath' const name = 'lexora_plugin_build' const inspectName = 'lexora_plugin_inspect' +const capabilitiesName = 'lexora_plugin_capabilities' +const capabilitiesParameters = Type.Object({ + kind: Type.Optional(Type.Union(workbenchCapabilityKinds.map(kind => Type.Literal(kind)))), + target: Type.Optional(Type.String({ minLength: 1, maxLength: 100, description: 'Exact target from the host catalog. Omit both filters for a compact index; specify either for detailed contracts.' })), +}, { additionalProperties: false }) const inspectParameters = Type.Object({ id: Type.String({ maxLength: 120, pattern: '^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$', description: 'Plugin ID to inspect. This only reads host status; it does not start or install the plugin.' }) }, { additionalProperties: false }) const parameters = Type.Object({ source: Type.String({ minLength: 1, maxLength: 4096, description: 'Plugin source directory containing extension.json, relative to the workspace or absolute.' }), @@ -24,6 +30,8 @@ const parameters = Type.Object({ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, peer: Pick): BuddyCapability { return { classify(event) { + if (event.toolName === capabilitiesName) + return Check(capabilitiesParameters, event.input) ? { access: 'read', paths: [] } : { blocked: true, reason: 'VALIDATION_FAILED' } if (event.toolName === inspectName) return Check(inspectParameters, event.input) ? { access: 'read', paths: [] } : { blocked: true, reason: 'VALIDATION_FAILED' } if (event.toolName !== name) @@ -32,10 +40,24 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, return { blocked: true, reason: 'VALIDATION_FAILED' } return { access: 'write', paths: [{ path: event.input.source, mode: 'existing' }, { path: event.input.output, mode: 'create' }] } }, - disclosure: { group: 'plugins', keywords: 'plugin extension build create inspect 插件 创建 编译 校验 安装 诊断', toolNames: [name, inspectName] }, + disclosure: { group: 'plugins', keywords: 'plugin extension build create inspect capabilities 插件 创建 编译 校验 安装 诊断 插槽', toolNames: [name, inspectName, capabilitiesName] }, extension: { name: 'lexora-plugin-authoring', factory(pi) { + pi.registerTool(defineTool({ + name: capabilitiesName, + label: 'Discover plugin capabilities', + parameters: capabilitiesParameters, + description: 'Query the actual Lexora host for contribution targets and runtime capabilities. No filters returns a compact index; filter by kind or exact target for sizing, selection, scope and interaction rules. Read-only.', + async execute(_toolCallId, input, signal) { + try { + const abort = signal ? AbortSignal.any([signal, context.signal]) : context.signal + const capabilities = extensionCapabilitiesSchema.parse(await peer.request(EXTENSION_CAPABILITIES_RPC, input, 10000, abort)) + return response({ ok: true, ...capabilities }) + } + catch { return response({ ok: false, code: 'EXTENSION_CAPABILITIES_FAILED' }) } + }, + })) pi.registerTool(defineTool({ name: inspectName, label: 'Inspect plugin', @@ -101,7 +123,7 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, }, })) pi.on('tool_result', (event) => { - if ([name, inspectName].includes(event.toolName) && event.details && typeof event.details === 'object' && 'ok' in event.details && event.details.ok === false) + if ([name, inspectName, capabilitiesName].includes(event.toolName) && event.details && typeof event.details === 'object' && 'ok' in event.details && event.details.ok === false) return { isError: true } }) }, diff --git a/apps/buddy/shared/conversation/buddyUserContent.ts b/apps/buddy/shared/conversation/buddyUserContent.ts index 34222b08..5ac06656 100644 --- a/apps/buddy/shared/conversation/buddyUserContent.ts +++ b/apps/buddy/shared/conversation/buddyUserContent.ts @@ -36,6 +36,7 @@ const skillDirectiveSchema = z.object({ const slashDirectiveSchema = z.object({ commandMode: z.enum(['prompt', 'action']), + commandId: z.string().min(1).max(180).regex(/^[a-z][a-z0-9.-]+$/).optional(), directive: z.literal('slash_command'), type: z.literal('prompt_directive'), value: z.string().min(1), diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index d2d798a8..d42f204d 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -1,3 +1,4 @@ +import type { WorkbenchHitRegion, WorkbenchPaneSnapshot } from '../workbench/workbenchInteraction' import type { JsonValue } from '../workbench/workbenchState' import type { ControlProposal, WorkbenchPresentation } from '../workbench/workbenchUi' import type { ExtensionCatalogSnapshot } from './extensionCatalog' @@ -5,6 +6,8 @@ import type { ExtensionInstallation } from './extensionInstallation' import type { ExtensionManifest } from './extensionManifest' import { z } from 'zod' import { spaceFileTargetSchema } from '../spaces/spaceFileApi' +import { workbenchPanesSchema } from '../workbench/workbenchInteraction' +import { workbenchMenuSchema } from '../workbench/workbenchUi' import { extensionIdSchema } from './extensionManifest' export const EXTENSION_IPC = { @@ -24,12 +27,21 @@ export const extensionViewInputSchema = z.object({ extensionId: extensionIdSchema, viewType: z.string().max(180), placementId: z.string().min(1).max(180).optional(), + interactionId: z.string().uuid().optional(), + instanceId: z.string().uuid().optional(), resource: extensionResourceSchema.nullable(), state: extensionJsonSchema, stateVersion: z.number().int().min(0).max(10000), }).strict() export type ExtensionResource = z.infer export type ExtensionViewInput = z.infer +export const extensionMenuInvocationSchema = z.object({ + target: workbenchMenuSchema, + instanceId: z.string().uuid().optional(), + content: z.string().max(131072).optional(), + resource: spaceFileTargetSchema.nullable().default(null), +}).strict() +export type ExtensionMenuInvocation = z.infer export interface ExtensionViewSession { id: string extensionId: string @@ -72,7 +84,10 @@ export type ExtensionWorkbenchEvent = | { kind: 'cancel', requestId: string } | { kind: 'open', requestId: string, extensionId: string, generation: string, viewType: string, resource: ExtensionResource | null, state: JsonValue, stateVersion: number } | { kind: 'state', requestId: string, viewId: string, generation: string, token: string, state: JsonValue, stateVersion: number } - | { kind: 'placement', requestId: string, extensionId: string, generation: string, placementId: string, visible: boolean } + | { kind: 'interaction', requestId: string, extensionId: string, generation: string, interactionId: string, title: string | null } + | { kind: 'message', requestId: string, extensionId: string, generation: string, message: JsonValue } + | { kind: 'regions', requestId: string, viewId: string, generation: string, token: string, regions: WorkbenchHitRegion[] } + | { kind: 'placement', requestId: string, extensionId: string, generation: string, placementId: string, visible: boolean, instanceId?: string, interactionId?: string } | { kind: 'control', requestId: string, viewId: string, generation: string, token: string, proposal: ControlProposal } | { kind: 'presentation', requestId: string, viewId: string, generation: string, token: string, presentation: WorkbenchPresentation } @@ -91,6 +106,10 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('devtools'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('revokeResources'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('execute'), id: extensionIdSchema, command: z.string().max(180), resource: spaceFileTargetSchema.nullable() }).strict(), + z.object({ action: z.literal('executeMenu'), id: extensionIdSchema, menu: z.string().max(180), invocation: extensionMenuInvocationSchema }).strict(), + z.object({ action: z.literal('executeSlash'), id: extensionIdSchema, command: z.string().max(180), arguments: z.string().max(8192), instanceId: z.string().uuid().optional() }).strict(), + z.object({ action: z.literal('updatePanes'), panes: workbenchPanesSchema }).strict(), + z.object({ action: z.literal('endInteraction'), id: z.string().uuid() }).strict(), z.object({ action: z.literal('openView'), view: extensionViewInputSchema }).strict(), z.object({ action: z.literal('closeView'), viewId: z.string().uuid(), generation: z.string().uuid(), token: z.string().uuid() }).strict(), z.object({ action: z.literal('viewRequest'), viewId: z.string().uuid(), generation: z.string().uuid(), token: z.string().uuid(), method: z.string().max(80), params: extensionJsonSchema }).strict(), @@ -111,6 +130,10 @@ export interface ExtensionApi { devtools: (id: string) => Promise revokeResources: (id: string) => Promise execute: (id: string, command: string, resource: import('../spaces/spaceFileApi').SpaceFileTarget | null) => Promise + executeMenu: (id: string, menu: string, invocation: ExtensionMenuInvocation) => Promise + executeSlash: (id: string, command: string, argumentsText: string, instanceId?: string) => Promise + updatePanes: (panes: WorkbenchPaneSnapshot[]) => Promise + endInteraction: (id: string) => Promise openView: (view: ExtensionViewInput) => Promise closeView: (viewId: string, generation: string, token: string) => Promise viewRequest: (viewId: string, generation: string, token: string, method: string, params: JsonValue) => Promise diff --git a/apps/buddy/shared/extensions/extensionAuthoring.ts b/apps/buddy/shared/extensions/extensionAuthoring.ts index 60c25ab5..8c90a642 100644 --- a/apps/buddy/shared/extensions/extensionAuthoring.ts +++ b/apps/buddy/shared/extensions/extensionAuthoring.ts @@ -1,9 +1,25 @@ import { z } from 'zod' +import { workbenchCapabilityKinds } from '../workbench/workbenchContributionCatalog' +import { workbenchCapabilityQuerySchema } from '../workbench/workbenchUi' import { extensionIdSchema, extensionVersionSchema } from './extensionManifest' export const EXTENSION_BUILD_RPC = 'extensions.build' export const EXTENSION_REVIEW_REQUEST = 'extensions.reviewPackage' export const EXTENSION_INSPECT_RPC = 'extensions.inspect' +export const EXTENSION_CAPABILITIES_RPC = 'extensions.capabilities' +export { workbenchCapabilityQuerySchema as extensionCapabilitiesRequestSchema } +export const extensionCapabilitiesSchema = z.object({ + apiVersion: z.number().int().positive(), + targets: z.array(z.object({ + kind: z.enum(workbenchCapabilityKinds), + target: z.string(), + title: z.object({ 'zh-CN': z.string(), 'en-US': z.string() }).strict(), + scope: z.enum(['application', 'page', 'pane', 'composer', 'resource', 'message']), + description: z.string().optional(), + selection: z.enum(['single', 'multiple']).optional(), + height: z.object({ min: z.number(), max: z.number(), default: z.number() }).strict().optional(), + }).strict()), +}).strict() export const extensionInspectRequestSchema = z.object({ id: extensionIdSchema }).strict() export const extensionInspectionSchema = z.object({ id: extensionIdSchema, diff --git a/apps/buddy/shared/extensions/extensionCommands.ts b/apps/buddy/shared/extensions/extensionCommands.ts new file mode 100644 index 00000000..20115475 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionCommands.ts @@ -0,0 +1,3 @@ +export function extensionCommandNamespace(id: string): string { + return id.slice(id.indexOf('.') + 1) +} diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index 0619d37b..d2523771 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -1,9 +1,10 @@ import { satisfies, valid, validRange } from 'semver' import { z } from 'zod' +import { workbenchSlashSchema } from '../workbench/workbenchCommand' import { workbenchConditionSchema } from '../workbench/workbenchContext' -import { workbenchAnchorSchema, workbenchControlSchema, workbenchMountTargetSchema, workbenchPresentationSchema } from '../workbench/workbenchUi' +import { workbenchAnchorSchema, workbenchControls, workbenchControlSchema, workbenchMenuSchema, workbenchMountTargetSchema, workbenchPresentationSchema, workbenchSlots, workbenchSlotSchema } from '../workbench/workbenchUi' -export const EXTENSION_API_VERSION = 2 +export const EXTENSION_API_VERSION = 3 export const EXTENSION_PROTOCOL = 'lexora-extension' export const EXTENSION_ICON_LIMIT = 64 * 1024 export const extensionIconUrlSchema = z.string().max(Math.ceil(EXTENSION_ICON_LIMIT / 3) * 4 + 40).regex(/^data:image\/(?:svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/) @@ -19,6 +20,7 @@ export const extensionPlacementSchema = z.discriminatedUnion('kind', [ z.object({ ...placementBase, kind: z.literal('view'), + interaction: z.enum(['regions', 'exclusive']).optional(), target: workbenchMountTargetSchema.optional(), presentation: workbenchPresentationSchema.omit({ target: true }).default({}), location: z.enum(['workbench.top', 'workbench.bottom', 'workbench.floating']).optional(), @@ -27,24 +29,35 @@ export const extensionPlacementSchema = z.discriminatedUnion('kind', [ }).strict().superRefine((value, context) => { if (!!value.target === !!value.location || (value.target && (value.height !== undefined || value.width !== undefined))) context.addIssue({ code: 'custom', message: 'Declare a mount target and presentation, or a legacy location' }) - }).transform(({ id, view, kind, when, target, presentation, location, height, width }) => ({ + }).transform(({ id, view, kind, when, target, presentation, location, height, width, interaction }) => ({ id, view, kind, + ...(interaction ? { interaction } : {}), ...(when ? { when } : {}), target: target ?? 'workbench' as const, presentation: location ? { position: location === 'workbench.floating' ? 'absolute' as const : 'static' as const, order: location === 'workbench.top' ? -1 : 1, height: height ?? 56, ...(width === undefined ? {} : { width }), ...presentation } : presentation, })), z.object({ ...placementBase, kind: z.literal('decoration'), anchor: workbenchAnchorSchema }).strict(), - z.object({ ...placementBase, kind: z.literal('control'), target: workbenchControlSchema, height: z.number().int().min(32).max(160).default(64) }).strict(), -]) + z.object({ ...placementBase, kind: z.literal('control'), target: workbenchControlSchema, height: z.number().int().optional() }).strict(), + z.object({ ...placementBase, kind: z.literal('slot'), target: workbenchSlotSchema, height: z.number().int().optional() }).strict(), +]).transform((placement, context) => { + if (placement.kind !== 'slot' && placement.kind !== 'control') + return placement + const bounds = (placement.kind === 'slot' ? workbenchSlots[placement.target] : workbenchControls[placement.target]).height + const height = placement.height ?? bounds.default + if (height < bounds.min || height > bounds.max) + context.addIssue({ code: 'custom', message: `Height for ${placement.target} must be ${bounds.min}-${bounds.max}` }) + return { ...placement, height } +}) export type ExtensionPlacement = z.infer export const extensionPermissionsSchema = z.object({ windowEffects: z.boolean().default(false), - controls: z.array(workbenchControlSchema).max(1).default([]), + controls: z.array(workbenchControlSchema).max(workbenchControlSchema.options.length).default([]), notifications: z.boolean().default(false), schedules: z.boolean().default(false), selectedResource: z.enum(['none', 'read']).default('none'), + selectedContent: z.boolean().default(false), localResources: z.boolean().default(false), resourceExport: z.boolean().default(false), network: z.array(z.string().url().max(512).refine((value) => { @@ -62,21 +75,22 @@ export const extensionManifestSchema = z.object({ categories: z.array(z.string().min(1).max(40)).max(8).default([]), tags: z.array(z.string().min(1).max(40)).max(16).default([]), version: extensionVersionSchema, - apiVersion: z.union([z.literal(1), z.literal(EXTENSION_API_VERSION)]), + apiVersion: z.union([z.literal(1), z.literal(2), z.literal(EXTENSION_API_VERSION)]), engines: z.object({ lexora: z.string().max(100).refine(value => validRange(value) !== null) }).strict(), entry: extensionPathSchema.optional(), dataVersion: z.number().int().min(1).max(10000).default(1), dependencies: z.record(extensionIdSchema, z.string().max(100).refine(value => validRange(value) !== null)).default({}), permissions: extensionPermissionsSchema.prefault({}), contributes: z.object({ - commands: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), hidden: z.boolean().default(false), when: workbenchConditionSchema.optional() }).strict()).max(64).default([]), + commands: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), hidden: z.boolean().default(false), slash: workbenchSlashSchema.optional(), when: workbenchConditionSchema.optional() }).strict()).max(64).default([]), + menus: z.array(z.object({ id: contributionId, command: contributionId, target: workbenchMenuSchema, order: z.number().int().min(-1000).max(1000).default(0), when: workbenchConditionSchema.optional() }).strict()).max(32).default([]), views: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), entry: extensionPathSchema, stateVersion: z.number().int().min(1).max(10000).default(1), resource: z.enum(['selected-file', 'none']).default('selected-file'), location: z.enum(['context', 'page', 'window-overlay']).default('context'), when: workbenchConditionSchema.optional() }).strict()).max(16).default([]), placements: z.array(extensionPlacementSchema).max(16).default([]), navigation: z.object({ title: z.string().min(1).max(40), view: contributionId, when: workbenchConditionSchema.optional() }).strict().optional(), }).strict(), }).strict().superRefine((manifest, context) => { const ids = new Set() - for (const contribution of [...manifest.contributes.commands, ...manifest.contributes.views, ...manifest.contributes.placements]) { + for (const contribution of [...manifest.contributes.commands, ...manifest.contributes.views, ...manifest.contributes.placements, ...manifest.contributes.menus]) { if (!contribution.id.startsWith(`${manifest.id}.`) || ids.has(contribution.id)) context.addIssue({ code: 'custom', message: 'Contribution IDs must be unique and owned by the extension' }) ids.add(contribution.id) @@ -93,14 +107,32 @@ export const extensionManifestSchema = z.object({ context.addIssue({ code: 'custom', message: 'Window overlays require windowEffects permission' }) if (manifest.contributes.views.filter(view => view.location === 'window-overlay').length > 1) context.addIssue({ code: 'custom', message: 'An extension can provide one window overlay' }) - if (manifest.contributes.navigation && !manifest.contributes.views.some(view => view.id === manifest.contributes.navigation?.view && view.resource === 'none' && (view.location === 'page' || (manifest.apiVersion === 2 && view.location === 'context')))) + if (manifest.contributes.navigation && !manifest.contributes.views.some(view => view.id === manifest.contributes.navigation?.view && view.resource === 'none' && (view.location === 'page' || (manifest.apiVersion >= 2 && view.location === 'context')))) context.addIssue({ code: 'custom', message: 'Navigation must reference a page view' }) if (manifest.apiVersion === 1 && (manifest.contributes.placements.length || manifest.permissions.controls.length)) context.addIssue({ code: 'custom', message: 'UI placements and controls require API 2' }) + if (manifest.apiVersion < 3 && manifest.contributes.placements.some(placement => placement.kind === 'slot')) + context.addIssue({ code: 'custom', message: 'Content slots require API 3' }) + if (manifest.apiVersion < 3 && (manifest.contributes.menus.length || manifest.permissions.selectedContent || manifest.contributes.placements.some(placement => placement.kind === 'view' && placement.target === 'workbench.pane'))) + context.addIssue({ code: 'custom', message: 'Menus, selected content and pane mounts require API 3' }) + const slashNames = new Set() + for (const command of manifest.contributes.commands) { + if (!command.slash) + continue + if (manifest.apiVersion < 3 || command.hidden || slashNames.has(command.slash.name)) + context.addIssue({ code: 'custom', message: 'Slash commands require API 3, visible commands and unique names within the plugin' }) + slashNames.add(command.slash.name) + } + for (const menu of manifest.contributes.menus) { + if (!manifest.contributes.commands.some(command => command.id === menu.command)) + context.addIssue({ code: 'custom', message: 'Menus must reference an owned command' }) + } for (const placement of manifest.contributes.placements) { const view = manifest.contributes.views.find(view => view.id === placement.view) if (!view || view.resource !== 'none' || view.location !== 'context') context.addIssue({ code: 'custom', message: 'Placements require a resource-free view definition' }) + if (placement.kind === 'view' && placement.interaction && (manifest.apiVersion < 3 || !['workbench', 'workbench.pane'].includes(placement.target) || placement.presentation.position !== 'absolute')) + context.addIssue({ code: 'custom', message: 'Interactions require API 3 and an absolute workbench or pane mount' }) if (placement.kind === 'decoration' && !manifest.permissions.windowEffects) context.addIssue({ code: 'custom', message: 'Decorations require windowEffects permission' }) if (placement.kind === 'control' && !manifest.permissions.controls.includes(placement.target)) @@ -123,6 +155,7 @@ export function addedExtensionPermissions(previous: ExtensionPermissions | undef ...(next.notifications && !previous?.notifications ? ['notifications'] : []), ...(next.schedules && !previous?.schedules ? ['schedules'] : []), ...(next.selectedResource === 'read' && previous?.selectedResource !== 'read' ? ['selectedResource:read'] : []), + ...(next.selectedContent && !previous?.selectedContent ? ['selectedContent'] : []), ...(next.localResources && !previous?.localResources ? ['localResources'] : []), ...(next.resourceExport && !previous?.resourceExport ? ['resourceExport'] : []), ...next.network.filter(origin => !previous?.network.includes(origin)).map(origin => `network:${origin}`), diff --git a/apps/buddy/shared/workbench/workbenchCommand.ts b/apps/buddy/shared/workbench/workbenchCommand.ts new file mode 100644 index 00000000..b4a8a520 --- /dev/null +++ b/apps/buddy/shared/workbench/workbenchCommand.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const workbenchSlashSchema = z.object({ name: z.string().min(1).max(64).regex(/^[a-z][a-z0-9-]*$/), description: z.string().max(200).optional() }).strict() +export function qualifyWorkbenchCommand(namespace: string, name: string): string { + return `${namespace}:${name}` +} + +export interface WorkbenchSlashCommand { id: string, name: string, title: string, description?: string } +export function parseSlashInvocation(value: string): { name: string, arguments: string } | null { + const match = /^\/(\S+)(?:\s([\s\S]*))?$/u.exec(value.trim()) + return match ? { name: match[1]!, arguments: match[2]?.trimStart() ?? '' } : null +} diff --git a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts new file mode 100644 index 00000000..c022fab8 --- /dev/null +++ b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts @@ -0,0 +1,96 @@ +import { z } from 'zod' + +interface TargetDefinition { + title: { 'zh-CN': string, 'en-US': string } + scope: 'application' | 'page' | 'pane' | 'composer' | 'resource' | 'message' + description: string +} +interface ContentDefinition extends TargetDefinition { + selection: 'single' | 'multiple' + height: { min: number, max: number, default: number } +} + +export const workbenchSlots = { + 'composer.footer': { title: { 'zh-CN': '输入区下方', 'en-US': 'Below the composer' }, scope: 'composer', selection: 'single', height: { min: 20, max: 48, default: 28 }, description: 'Replace the short footer below each composer. The built-in reminder returns when unavailable.' }, + 'composer.accessory': { title: { 'zh-CN': '输入辅助区', 'en-US': 'Composer accessories' }, scope: 'composer', selection: 'multiple', height: { min: 32, max: 240, default: 64 }, description: 'Add independent panels above each composer. Selected providers appear in user order; no draft content is shared.' }, + 'task.welcome': { title: { 'zh-CN': '新任务欢迎区', 'en-US': 'New task welcome' }, scope: 'pane', selection: 'single', height: { min: 64, max: 400, default: 240 }, description: 'Replace the welcome artwork and greeting while a task has no messages. The composer remains host-owned.' }, + 'workbench.pane.empty': { title: { 'zh-CN': '空白分屏', 'en-US': 'Empty pane' }, scope: 'pane', selection: 'single', height: { min: 64, max: 640, default: 240 }, description: 'Replace the empty surface of a pane without a view. Unavailable providers restore the built-in launcher.' }, +} as const satisfies Record + +export const workbenchControls = { + 'model.reasoning': { title: { 'zh-CN': '思考等级控件', 'en-US': 'Reasoning control' }, scope: 'composer', selection: 'single', height: { min: 32, max: 160, default: 64 }, description: 'Replace the reasoning selector using host-owned options and revision-checked proposals. Requires controls permission.' }, +} as const satisfies Record + +export const workbenchMounts = { + 'workbench': { title: { 'zh-CN': '工作台', 'en-US': 'Workbench' }, scope: 'application', description: 'Persistent mount across pages. Supports static or absolute presentation and placements.show/hide.' }, + 'app.sidebar': { title: { 'zh-CN': '应用侧栏', 'en-US': 'Application sidebar' }, scope: 'application', description: 'Mount within the application sidebar.' }, + 'workbench.sidebar': { title: { 'zh-CN': '任务侧栏', 'en-US': 'Task sidebar' }, scope: 'page', description: 'Mount within the task sidebar; the view is parked while the sidebar is unavailable.' }, + 'workbench.pane': { title: { 'zh-CN': '分屏区域', 'en-US': 'Pane' }, scope: 'pane', description: 'Mount in a specific pane. Pass the originating instanceId to placements.show/hide; omission selects the active pane. Each pane has independent view state.' }, +} as const satisfies Record + +export const workbenchMenus = { + 'composer.actions': { title: { 'zh-CN': '输入操作', 'en-US': 'Composer actions' }, scope: 'composer', description: 'Commands next to composer tools. A click may share draft text with selectedContent permission. Returning {insertText:string} inserts plain text only if the captured draft is unchanged; it never sends.' }, + 'task.actions': { title: { 'zh-CN': '任务操作', 'en-US': 'Task actions' }, scope: 'pane', description: 'Commands in the task header. Receives the originating pane instanceId, without task identity or history.' }, + 'resource.actions': { title: { 'zh-CN': '文件操作', 'en-US': 'File actions' }, scope: 'resource', description: 'Commands for the selected file. selectedResource:read grants an opaque handle through existing directory authorization.' }, + 'message.actions': { title: { 'zh-CN': '消息操作', 'en-US': 'Message actions' }, scope: 'message', description: 'Commands on a completed message. A click may share that message text with selectedContent permission; no conversation history is shared.' }, +} as const satisfies Record + +export const workbenchDecorations = { + 'app.sidebar': { title: { 'zh-CN': '应用侧栏装饰', 'en-US': 'Application sidebar decoration' }, scope: 'application', description: 'Noninteractive decoration; windowEffects permission required.' }, + 'workbench.sidebar': { title: { 'zh-CN': '任务侧栏装饰', 'en-US': 'Task sidebar decoration' }, scope: 'page', description: 'Noninteractive task-sidebar decoration.' }, + 'workbench.pane': { title: { 'zh-CN': '分屏装饰', 'en-US': 'Pane decoration' }, scope: 'pane', description: 'Independent noninteractive decoration per pane; receives local geometry and filtered input activity.' }, + 'composer.input': { title: { 'zh-CN': '输入框装饰', 'en-US': 'Composer decoration' }, scope: 'composer', description: 'Noninteractive decoration per input; activity includes caret geometry, never text or keys.' }, +} as const satisfies Record + +export const workbenchRuntimeCapabilities = { + 'commands': { title: { 'zh-CN': '注册输入命令', 'en-US': 'Registered commands' }, scope: 'application', description: 'API 3 commands may declare slash:{name,description?}. Host exposes /plugin-name:name only (plugin-name is the part after the publisher in the plugin ID); top-level names are system-owned. Local names match [a-z][a-z0-9-]* (max 64). Titles and descriptions may be localized. Duplicate local names or invalid/missing handler registrations fail validation or activation. Another active plugin using the same plugin namespace prevents activation. Slash executes exact names with string arguments and origin pane; no model request. See commands.md.' }, + 'workbench.panes': { title: { 'zh-CN': '分屏布局订阅', 'en-US': 'Pane snapshots' }, scope: 'application', description: 'API 3 host workbench.panes and onPanesChange expose opaque id, active, visible, and rect relative to the workbench mount. Subscribe to layout and visibility changes; no task identity, history, or DOM access. See interactions.md.' }, + 'workbench.interactions': { title: { 'zh-CN': '临时交互', 'en-US': 'Transient interactions' }, scope: 'application', description: 'API 3 interactions.start(title) returns id, AbortSignal, end(). Mount placements on workbench/workbench.pane declare interaction:regions or exclusive and require {interactionId,instanceId?}. Host exit and Escape revoke views without waiting for plugin cleanup. Regions mode only receives declared hit regions via view.interaction; other pixels pass through. Sessions never restore after restart. See interactions.md.' }, +} as const satisfies Record + +function keys>(entries: T): [keyof T & string, ...(keyof T & string)[]] { + return Object.keys(entries) as [keyof T & string, ...(keyof T & string)[]] +} +export const workbenchSlotSchema = z.enum(keys(workbenchSlots)) +export const workbenchControlSchema = z.enum(keys(workbenchControls)) +export const workbenchMountTargetSchema = z.enum(keys(workbenchMounts)) +export const workbenchMenuSchema = z.enum(keys(workbenchMenus)) +export const workbenchAnchorSchema = z.enum(keys(workbenchDecorations)) +export type WorkbenchSlot = keyof typeof workbenchSlots +export type WorkbenchControl = keyof typeof workbenchControls +export type WorkbenchMountTarget = keyof typeof workbenchMounts +export type WorkbenchMenu = keyof typeof workbenchMenus +export type WorkbenchAnchor = keyof typeof workbenchDecorations + +function catalog>(kind: K, targets: T) { + return keys(targets).map(target => ({ kind, target, ...targets[target] })) +} +export const workbenchUiTargetCatalog = [...catalog('control', workbenchControls), ...catalog('slot', workbenchSlots)] +export type WorkbenchUiSelectionTarget = Pick<(typeof workbenchUiTargetCatalog)[number], 'kind' | 'target'> +export const workbenchContributionCatalog = [...workbenchUiTargetCatalog, ...catalog('mount', workbenchMounts), ...catalog('menu', workbenchMenus), ...catalog('decoration', workbenchDecorations), ...catalog('runtime', workbenchRuntimeCapabilities)] +export const workbenchCapabilityKinds = ['slot', 'control', 'mount', 'menu', 'decoration', 'runtime'] as const +export const workbenchCapabilityQuerySchema = z.object({ kind: z.enum(workbenchCapabilityKinds).optional(), target: z.string().min(1).max(100).optional() }).strict() +export function queryWorkbenchCapabilities(query: z.infer) { + return workbenchContributionCatalog.filter(entry => (!query.kind || entry.kind === query.kind) && (!query.target || entry.target === query.target)) +} +export function workbenchUiSelectionKey(selection: WorkbenchUiSelectionTarget): string { + return `workbench.${selection.kind === 'control' ? 'controls' : 'slots'}.${selection.target}` +} +export function parseWorkbenchUiSelection(value: unknown, multiple: boolean): string[] | null { + if (typeof value !== 'string') + return null + if (!value) + return [] + if (!multiple) + return value.length <= 180 ? [value] : null + try { + const parsed: unknown = JSON.parse(value) + return Array.isArray(parsed) && parsed.length <= 8 && parsed.every(id => typeof id === 'string' && id.length > 0 && id.length <= 180) && new Set(parsed).size === parsed.length ? parsed : null + } + catch { + return null + } +} +export function workbenchMountKey(target: WorkbenchMountTarget, instanceId?: string | null): string { + return workbenchMounts[target].scope === 'pane' ? `${target}:${instanceId ?? ''}` : target +} diff --git a/apps/buddy/shared/workbench/workbenchInteraction.ts b/apps/buddy/shared/workbench/workbenchInteraction.ts new file mode 100644 index 00000000..a3224097 --- /dev/null +++ b/apps/buddy/shared/workbench/workbenchInteraction.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const workbenchRectSchema = z.object({ x: z.number().finite(), y: z.number().finite(), width: z.number().finite().nonnegative(), height: z.number().finite().nonnegative() }).strict() +export const workbenchPaneSnapshotSchema = z.object({ id: z.string().uuid(), active: z.boolean(), visible: z.boolean(), rect: workbenchRectSchema }).strict() +export const workbenchPanesSchema = z.array(workbenchPaneSnapshotSchema).max(16).refine(panes => new Set(panes.map(pane => pane.id)).size === panes.length) +export type WorkbenchPaneSnapshot = z.infer +export const workbenchHitRegionsSchema = z.array(z.object({ id: z.string().min(1).max(80), label: z.string().min(1).max(120), rect: workbenchRectSchema }).strict()).max(64).refine(regions => new Set(regions.map(region => region.id)).size === regions.length) +export type WorkbenchHitRegion = z.infer[number] +export interface WorkbenchRegionActivation { id: string, x: number, y: number } +export interface WorkbenchInteraction { id: string, title: string } diff --git a/apps/buddy/shared/workbench/workbenchUi.ts b/apps/buddy/shared/workbench/workbenchUi.ts index dcf165ce..dc0bc6e2 100644 --- a/apps/buddy/shared/workbench/workbenchUi.ts +++ b/apps/buddy/shared/workbench/workbenchUi.ts @@ -1,8 +1,8 @@ +import type { WorkbenchAnchor, WorkbenchMountTarget } from './workbenchContributionCatalog' import { z } from 'zod' +import { workbenchMountTargetSchema } from './workbenchContributionCatalog' -export const workbenchAnchorSchema = z.enum(['app.sidebar', 'workbench.sidebar', 'workbench.pane', 'composer.input']) -export const workbenchControlSchema = z.enum(['model.reasoning']) -export const workbenchMountTargetSchema = z.enum(['workbench', 'app.sidebar', 'workbench.sidebar']) +export * from './workbenchContributionCatalog' const mountLengthSchema = z.union([z.number().min(0).max(8192), z.string().regex(/^(?:100|\d{1,2})(?:\.\d{1,3})?%$/).refine(value => Number.parseFloat(value) <= 100)]) export const workbenchPresentationSchema = z.object({ target: workbenchMountTargetSchema.optional(), @@ -18,12 +18,10 @@ export const workbenchPresentationSchema = z.object({ }).strict() export const extensionPresentationRequestSchema = workbenchPresentationSchema export type WorkbenchPresentation = z.infer -export type WorkbenchMountTarget = z.infer -export type WorkbenchAnchor = z.infer -export type WorkbenchControl = z.infer export interface MountGeometry { target: WorkbenchMountTarget + instanceId?: string visible: boolean width: number height: number diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index c626af13..e9c86030 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -3,12 +3,12 @@ import type { DesktopShellBindings } from '../shell/desktopShellBindings' import type { DesktopBrowserGuestSurfaceHost } from '@/platform/browser/browserGuestSurface' import { DEFAULT_DESKTOP_CHAT_PREFERENCES } from '@buddy-electron/shared/desktopApi' import { useMessage } from 'naive-ui' -import { computed, nextTick, onScopeDispose, provide, toRef, useTemplateRef, watch } from 'vue' +import { computed, nextTick, onMounted, onScopeDispose, provide, ref, toRef, useTemplateRef, watch } from 'vue' import { useRouter } from 'vue-router' import { resolveBuddyLocale, translateBuddy } from '@/i18n/buddyI18n' import { useProvideAutomationContext } from '@/modules/automations' -import { useExtensionState, useExtensionViews, useProvideExtensionContext } from '@/modules/extensions' -import { DesktopExtensionControl, DesktopExtensionFrameHost, DesktopExtensionOverlays, DesktopExtensionReviewHost } from '@/modules/extensions/ui' +import { useExtensionState, useExtensionUiContributions, useExtensionViews, useProvideExtensionContext } from '@/modules/extensions' +import { DesktopExtensionControl, DesktopExtensionFrameHost, DesktopExtensionMenu, DesktopExtensionOverlays, DesktopExtensionReviewHost, DesktopExtensionSlot } from '@/modules/extensions/ui' import { useProvideSettingsContext } from '@/modules/settings' import { useProvideSkillsContext } from '@/modules/skills' import { useProvideTaskEnvironment, useTaskIndex, useTaskResourcePanel } from '@/modules/tasks' @@ -16,9 +16,11 @@ import DesktopBrowserGuestHost from '@/platform/browser/DesktopBrowserGuestHost. import { useBrowserGuestHost } from '@/platform/browser/useBrowserGuestHost' import { requireDesktopApi } from '@/platform/desktop/desktopApi' import { runtimeAvailabilityKey } from '@/platform/runtime/runtimeAvailability' +import { useProvideWorkbenchCommands } from '@/shared/ui/contributions/workbenchCommands' import { useProvideWorkbenchUi } from '@/shared/ui/contributions/workbenchUiContext' import { useProvideDesktopUi } from '@/shared/ui/desktopUiContext' import { SemanticAnchorRegistry } from '@/workbench/browser/surfaces/SemanticAnchorRegistry' +import { WorkbenchPaneRegistry } from '@/workbench/browser/surfaces/WorkbenchPaneRegistry' import WorkbenchSurfaceHost from '@/workbench/browser/surfaces/WorkbenchSurfaceHost.vue' import { useDesktopPages } from '../router/useDesktopPages' import { useDesktopShellState } from '../shell/useDesktopShellState' @@ -55,6 +57,32 @@ const workbench = useDesktopWorkbench({ api, stores, taskIndex, router, resource provide(desktopWorkbenchKey, workbench) const extensions = useExtensionState(api.extensions) const pages = useDesktopPages(router, extensions.installed, stores.applicationSettings.language) +const paneRegistry = new WorkbenchPaneRegistry(() => workbench.controller.layout.activePane) +onMounted(() => paneRegistry.start()) +onScopeDispose(() => paneRegistry.dispose()) +onScopeDispose(workbench.controller.subscribe(paneRegistry.invalidate)) +onScopeDispose(paneRegistry.subscribe(() => { + void api.extensions.updatePanes(paneRegistry.snapshot).catch(() => {}) +})) +const commandRevision = ref(0) +onScopeDispose(workbench.controller.registry.subscribe(() => commandRevision.value++)) +onScopeDispose(workbench.controller.subscribe(() => commandRevision.value++)) +useProvideWorkbenchCommands({ + reportFailure: () => message.error(translateBuddy(stores.applicationSettings.language.value, 'desktop.command.inputFailed')), + entries: computed(() => { + void commandRevision.value + return [...workbench.controller.registry.commands.values()].flatMap(command => command.slash && (!command.enabled || command.enabled(workbench.controller.context)) ? [{ id: command.id, name: command.slash.name, title: command.label, description: command.slash.description }] : []) + }), + execute: async (id, argumentsText, instanceId) => { + const controller = workbench.controller + const command = controller.registry.commands.get(id) + const pane = instanceId ? controller.pane(instanceId) : controller.context.pane + const context = { ...controller.context, pane, source: 'slash' as const, arguments: argumentsText } + if (!command?.slash || (instanceId && !pane) || (command.enabled && !command.enabled(context))) + throw new Error('EXTENSION_COMMAND_UNAVAILABLE') + return (await command.execute(context) ?? null) as import('@buddy-shared/workbench/workbenchState').JsonValue + }, +}) const extensionViews = useExtensionViews(api.extensions, extensions.installed, computed(() => pages.context.value.values)) watch(() => pages.context.value.values, (values, _, cleanup) => { const leases = Object.entries(values).map(([key, value]) => workbench.controller.contextKeys.set(key, value)) @@ -63,9 +91,10 @@ watch(() => pages.context.value.values, (values, _, cleanup) => { }, { immediate: true, flush: 'sync' }) const anchors = new SemanticAnchorRegistry() onScopeDispose(() => anchors.dispose()) -useProvideWorkbenchUi({ anchors, controlRenderer: DesktopExtensionControl }) -const controls = useExtensionContributions({ controller: workbench.controller, renderers: workbench.renderers, persistence: workbench.persistence, installed: extensions.installed, api: api.extensions, views: extensionViews, ready: () => workbench.initialized }) -useProvideExtensionContext({ state: extensions, views: extensionViews, anchors, controls, workbench: pages.context, language: stores.applicationSettings.language, isDark: toRef(() => props.isDark), startCreation: prompt => workbench.startTaskWithSkill('plugin-creator', prompt), focusView: (id) => { +useProvideWorkbenchUi({ anchors, panes: paneRegistry, controlRenderer: DesktopExtensionControl, slotRenderer: DesktopExtensionSlot, menuRenderer: DesktopExtensionMenu }) +useExtensionContributions({ controller: workbench.controller, renderers: workbench.renderers, persistence: workbench.persistence, installed: extensions.installed, api: api.extensions, views: extensionViews, ready: () => workbench.initialized }) +const ui = useExtensionUiContributions(extensions.installed, workbench.controller.configuration) +useProvideExtensionContext({ state: extensions, views: extensionViews, anchors, ui, workbench: pages.context, language: stores.applicationSettings.language, isDark: toRef(() => props.isDark), startCreation: prompt => workbench.startTaskWithSkill('plugin-creator', prompt), endInteraction: id => workbench.controller.interactions.end(id), focusView: (id) => { workbench.controller.focus(id) } }) onScopeDispose(workbench.controller.subscribe(() => void nextTick(extensionViews.layout))) diff --git a/apps/buddy/src/app/workbench/DesktopTaskContribution.vue b/apps/buddy/src/app/workbench/DesktopTaskContribution.vue index f60c2dfd..3c826d12 100644 --- a/apps/buddy/src/app/workbench/DesktopTaskContribution.vue +++ b/apps/buddy/src/app/workbench/DesktopTaskContribution.vue @@ -41,12 +41,14 @@ async function load() { if (disposed) return task.value = loaded - if (workbench.controller.owner(props.view.id)?.id === workbench.controller.layout.activePane) + if (props.visible && workbench.controller.owner(props.view.id)?.id === workbench.controller.layout.activePane) workbench.activeTask.value = loaded } catch { - if (!disposed) + if (!disposed) { failed.value = true + workbench.controller.navigation.fail(props.view.id) + } } } watch(() => props.view.resource.id, load, { immediate: true }) @@ -62,7 +64,7 @@ watch(() => props.visible, (visible) => {