diff --git a/.playwright/scripts/__tests__/extensionContributions.e2e.mjs b/.playwright/scripts/__tests__/extensionContributions.e2e.mjs new file mode 100644 index 00000000..3743126f --- /dev/null +++ b/.playwright/scripts/__tests__/extensionContributions.e2e.mjs @@ -0,0 +1,85 @@ +import path from 'node:path' +import { expect, test } from '../fixtures/electron.mjs' +import { writeContributionPlugin } from './extensionContributions.mjs' + +test('plugin-owned preferences activate contributions, yield native content and restore across restart', async ({ buddy }) => { + const instance = await buddy.createInstance('plugin-contributions') + let { app, page, diagnostics } = await instance.launch() + const navigate = async name => page.locator('.desktop-app-sidebar').getByRole('button', { name, exact: true }).click() + const nativeFooter = () => page.locator('.desktop-chat-composer__disclaimer') + const pluginFrame = async (name, file) => { + let result + await expect.poll(async () => { + for (const candidate of page.frames()) { + if (await candidate.locator(`[data-contribution-fixture="tests.${name}.${file}"]`).count().catch(() => 0)) { + result = candidate + return true + } + } + return false + }).toBe(true) + return result + } + const visible = async name => (await pluginFrame(name, 'footer')).evaluate(() => window.fixture.visible) + async function install(name) { + const directory = path.join(instance.home, 'fixtures', name) + const id = await writeContributionPlugin(directory, name) + await navigate('插件') + await app.evaluate(({ dialog }, directory) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] }) + }, directory) + await page.getByTestId('extension-install-options').click() + await page.getByTestId('extension-development').click() + await page.getByTestId('extension-confirm-install').click() + await expect.poll(() => page.evaluate(async id => (await window.lexoraDesktop.extensions.list()).some(item => item.manifest.id === id), id)).toBe(true) + await expect(page.getByText('界面呈现', { exact: true })).toHaveCount(0) + } + async function preference(name, enabled) { + await navigate(`Settings ${name}`) + const settings = await pluginFrame(name, 'settings') + await settings.getByRole('checkbox', { name: 'Enable footer' }).setChecked(enabled) + await expect(settings.getByRole('status')).toHaveText(enabled ? 'Enabled' : 'Disabled') + await navigate('任务') + } + await install('alpha') + await navigate('任务') + await expect(nativeFooter()).toBeVisible() + await preference('alpha', true) + const alpha = await pluginFrame('alpha', 'footer') + await expect(alpha.getByText('tests.alpha content')).toBeVisible() + await expect(nativeFooter()).toHaveCount(0) + const identity = await alpha.evaluate(() => window.fixture.instance) + const alphaActions = await pluginFrame('alpha', 'actions') + await alphaActions.getByRole('checkbox', { name: 'Content tests.alpha' }).uncheck() + await expect(nativeFooter()).toBeVisible() + await alphaActions.getByRole('checkbox', { name: 'Content tests.alpha' }).check() + await expect(alpha.getByText('tests.alpha content')).toBeVisible() + expect(await alpha.evaluate(() => window.fixture.instance)).toBe(identity) + await preference('alpha', false) + await expect(nativeFooter()).toBeVisible() + await preference('alpha', true) + + await install('beta') + await preference('beta', true) + await expect.poll(() => visible('alpha')).toBe(true) + await expect.poll(() => visible('beta')).toBe(false) + await preference('alpha', false) + await expect.poll(() => visible('beta')).toBe(true) + await expect(nativeFooter()).toHaveCount(0) + await preference('alpha', true) + await expect.poll(() => visible('alpha')).toBe(true) + await expect.poll(() => visible('beta')).toBe(false) + + await instance.stop() + ;({ app, page, diagnostics } = await instance.launch()) + await navigate('任务') + const restoredAlpha = await pluginFrame('alpha', 'footer') + await expect.poll(() => restoredAlpha.evaluate(() => window.fixture.visible)).toBe(true) + await page.evaluate(() => window.lexoraDesktop.extensions.enable('tests.alpha', false)) + const restoredBeta = await pluginFrame('beta', 'footer') + await expect.poll(() => restoredBeta.evaluate(() => window.fixture.visible)).toBe(true) + await page.evaluate(() => window.lexoraDesktop.extensions.enable('tests.beta', false)) + await expect(nativeFooter()).toBeVisible() + await page.screenshot({ path: path.join(instance.artifactDirectory, 'native-fallback.png'), animations: 'disabled' }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) diff --git a/.playwright/scripts/__tests__/extensionContributions.mjs b/.playwright/scripts/__tests__/extensionContributions.mjs new file mode 100644 index 00000000..473fdd89 --- /dev/null +++ b/.playwright/scripts/__tests__/extensionContributions.mjs @@ -0,0 +1,93 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +export async function writeContributionPlugin(directory, name) { + const id = `tests.${name}` + const manifest = { + schemaVersion: 1, + format: 'compiled', + id, + name: `Contribution ${name}`, + version: '1.0.0', + apiVersion: 3, + engines: { lexora: '*' }, + entry: 'host.js', + contributes: { + commands: ['read', 'save'].map(command => ({ id: `${id}.${command}`, title: command, hidden: true })), + views: [ + { id: `${id}.settings`, title: `Settings ${name}`, entry: 'settings.js', resource: 'none', location: 'page' }, + { id: `${id}.footer`, title: `Footer ${name}`, entry: 'footer.js', resource: 'none' }, + { id: `${id}.actions`, title: `Actions ${name}`, entry: 'actions.js', resource: 'none' }, + ], + navigation: { title: `Settings ${name}`, view: `${id}.settings` }, + placements: [ + { id: `${id}.footer-slot`, view: `${id}.footer`, kind: 'slot', target: 'composer.footer', enabled: false, height: 28, when: { 'page.id': 'lexora.tasks' } }, + { id: `${id}.actions-slot`, view: `${id}.actions`, kind: 'slot', target: 'composer.accessory', height: 40, when: { 'page.id': 'lexora.tasks' } }, + ], + }, + } + const files = { + 'extension.json': JSON.stringify(manifest), + 'host.js': `export const activate = ${activate.toString()}`, + 'settings.js': `export const render = (...args) => (${settings.toString()})(${JSON.stringify(id)}, ...args)`, + 'footer.js': `export const render = (...args) => (${footer.toString()})(${JSON.stringify(id)}, ...args)`, + 'actions.js': `export const render = (...args) => (${actions.toString()})(${JSON.stringify(id)}, ...args)`, + } + await fs.mkdir(directory, { recursive: true }) + await Promise.all(Object.entries(files).map(([file, content]) => fs.writeFile(path.join(directory, file), content))) + return id +} + +async function activate(context) { + const id = context.extension.id + let state = { enabled: false, active: true, ...await context.storage.get() } + const apply = async () => { + await context.placements[state.enabled ? 'show' : 'hide'](`${id}.footer-slot`) + await context.views.broadcast(state) + } + context.subscriptions.add(context.commands.register(`${id}.read`, () => state)) + context.subscriptions.add(context.commands.register(`${id}.save`, async ({ arguments: patch }) => { + state = { ...state, ...patch } + await context.storage.set(state) + await apply() + return state + })) + await apply() +} + +async function settings(pluginId, context, container) { + container.dataset.contributionFixture = `${pluginId}.settings` + container.innerHTML = '

Plugin preferences

' + const checkbox = container.querySelector('input') + checkbox.checked = (await context.commands.execute(`${pluginId}.read`)).enabled + checkbox.onchange = async () => { + await context.commands.execute(`${pluginId}.save`, { enabled: checkbox.checked }) + container.querySelector('[role=status]').textContent = checkbox.checked ? 'Enabled' : 'Disabled' + } +} + +async function footer(pluginId, context, container) { + container.dataset.contributionFixture = `${pluginId}.footer` + window.fixture = { instance: crypto.randomUUID(), visible: context.visible } + context.onVisibilityChange(visible => window.fixture.visible = visible) + container.style.cssText = 'height:100%;display:grid;place-items:center;color:var(--lexora-text);font:14px system-ui' + container.textContent = `${pluginId} content` + const apply = state => context.setActive(state.active) + context.onMessage(state => void apply(state)) + await apply(await context.commands.execute(`${pluginId}.read`)) +} + +async function actions(pluginId, context, container) { + container.dataset.contributionFixture = `${pluginId}.actions` + container.style.cssText = 'display:flex;align-items:center;height:100%;font:14px system-ui;color:var(--lexora-text)' + const label = document.createElement('label') + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.checked = (await context.commands.execute(`${pluginId}.read`)).active + label.append(checkbox, ` Content ${pluginId}`) + container.append(label) + context.onMessage(state => checkbox.checked = state.active) + checkbox.onchange = async () => { + await context.commands.execute(`${pluginId}.save`, { active: checkbox.checked }) + } +} diff --git a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts index 0e193234..e7b65de6 100644 --- a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts +++ b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts @@ -22,8 +22,10 @@ it('discovers a compact host index and exact target contracts without loading pl 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 } })]) + expect(detail.targets[0]!.description).toContain('ViewContext.setActive(false)') + expect(detail.targets[0]!.description).toContain('Plugin-owned settings') 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.parse({ ...placement, enabled: false })).toMatchObject({ height: detail.targets[0]!.height!.default, enabled: false }) 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']) diff --git a/apps/buddy/electron/main/extensions/runtime/view.js b/apps/buddy/electron/main/extensions/runtime/view.js index e9dc0228..f9736281 100644 --- a/apps/buddy/electron/main/extensions/runtime/view.js +++ b/apps/buddy/electron/main/extensions/runtime/view.js @@ -182,6 +182,7 @@ async function initialize() { return subscribe(activityListeners, listener) }, setState: state => request('view.setState', state), + setActive: active => request('view.setActive', { active }), setPresentation: presentation => request('view.setPresentation', presentation), resources: { readText: resource => request('resources.readText', { id: resource.id }), diff --git a/apps/buddy/extensions/README.md b/apps/buddy/extensions/README.md index 35f81d9d..d2ed156c 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` 提供装饰、工作台面板与思考等级控件。API 3 增加可选择的内容区、操作菜单、独立分屏挂载、带插件命名空间的输入命令与可取消交互。创作时用 `lexora_plugin_capabilities` 按需查询当前宿主支持的位置。内容仍在隔离视图运行;清单和调用示例见 [插件协议](../service/resources/skills/plugin-creator/references/protocol.md)。旧版插件继续可用。 +API 2 通过 `contributes.placements` 提供装饰、工作台面板与思考等级控件。API 3 增加可替换的内容区、操作菜单、独立分屏挂载、带插件命名空间的输入命令与可取消交互。创作时用 `lexora_plugin_capabilities` 按需查询当前宿主支持的位置。内容仍在隔离视图运行;清单和调用示例见 [插件协议](../service/resources/skills/plugin-creator/references/protocol.md)。插件自行提供配置交互,内容区及控件可由插件启停并交还宿主默认内容;具体用法见 [内容插槽](../service/resources/skills/plugin-creator/references/slots.md)。旧版插件继续可用。 diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index d0b9e7d3..78d085b3 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -419,6 +419,15 @@ export class ExtensionService { if (method === 'bootstrap') { 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.setActive') { + if (placement?.kind !== 'slot' && placement?.kind !== 'control') + throw new Error('EXTENSION_METHOD_DENIED') + const { active } = z.object({ active: z.boolean() }).strict().parse(params) + const accepted = await this.#ports.workbench({ kind: 'activity', requestId: randomUUID(), viewId: id, generation, token, active }, view.abort.signal) + if (accepted !== id) + throw new Error('EXTENSION_VIEW_UNAVAILABLE') + result = null + } else if (method === 'view.setState') { if (contribution.location !== 'context' || (placement && placement.kind !== 'view')) throw new Error('EXTENSION_METHOD_DENIED') @@ -779,14 +788,21 @@ export class ExtensionService { else if (method === 'placements.show' || method === 'placements.hide') { 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' || (input.instanceId && placement.target !== 'workbench.pane')) + if (!placement || placement.kind === 'decoration' || (placement.kind !== 'view' && (input.instanceId || input.interactionId)) || (input.instanceId && placement.target !== 'workbench.pane')) throw new Error('EXTENSION_PLACEMENT_UNAVAILABLE') const interaction = input.interactionId ? this.#interactions.get(input.interactionId) : null - if (!!placement.interaction !== !!input.interactionId || (input.interactionId && interaction?.running !== running)) + if (!!(placement.kind === 'view' && 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) + const requestId = randomUUID() + result = await this.#ports.workbench({ kind: 'placement', requestId, 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 (placement.kind !== 'view') { + if (result !== requestId) + throw new Error('EXTENSION_PLACEMENT_UNAVAILABLE') + result = placement.id + } + else if (method === 'placements.show' && !result) { throw new Error('EXTENSION_VIEW_UNAVAILABLE') + } } else if (method === 'views.open') { const input = z.object({ type: z.string().max(180), resource: extensionResourceSchema.nullable(), state: extensionJsonSchema }).strict().parse(params) diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts index b838720a..25932ea2 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 === 'interaction' ? event.interactionId : event.kind === 'state' || event.kind === 'regions' ? event.viewId : randomUUID() + return event.kind === 'placement' ? event.requestId : event.kind === 'interaction' ? event.interactionId : event.kind === 'state' || event.kind === 'regions' || event.kind === 'activity' ? event.viewId : randomUUID() }, readText: async target => `Content of ${target.path}`, get: async () => new Response('network'), @@ -176,7 +176,7 @@ it('stops dependent hosts when their dependency is disabled', async () => { it('authorizes each placement and control request against its own live view session', async () => { const { root, store, service, hosts, events } = await fixture({ workbench: async (event) => { events.push(event) - return event.kind === 'control' ? event.viewId : randomUUID() + return event.kind === 'control' ? event.viewId : event.kind === 'placement' ? event.requestId : randomUUID() } }) const value = manifest({ apiVersion: 2, id: 'tests.controls', permissions: { controls: ['model.reasoning'] }, contributes: { views: [{ id: 'tests.controls.ui', title: 'Controls', entry: 'view.js', resource: 'none' }], @@ -198,14 +198,17 @@ it('authorizes each placement and control request against its own live view sess await expect(service.viewRequest(ordinary.id, ordinary.generation, ordinary.token, 'control.propose', proposal)).rejects.toThrow('EXTENSION_METHOD_DENIED') await hosts[0]!.broker('placements.show', { id: 'tests.controls.top' }) expect(events).toContainEqual(expect.objectContaining({ kind: 'placement', extensionId: value.id, placementId: 'tests.controls.top', visible: true })) - await expect(hosts[0]!.broker('placements.show', { id: 'tests.controls.reasoning' })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') + await hosts[0]!.broker('placements.show', { id: 'tests.controls.reasoning' }) + expect(events).toContainEqual(expect.objectContaining({ kind: 'placement', placementId: 'tests.controls.reasoning', visible: true })) + await expect(hosts[0]!.broker('placements.show', { id: 'tests.controls.reasoning', instanceId: randomUUID() })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') + await expect(service.viewRequest(ordinary.id, ordinary.generation, ordinary.token, 'view.setActive', { active: false })).rejects.toThrow('EXTENSION_METHOD_DENIED') await expect(hosts[0]!.broker('placements.show', { id: 'another.plugin.top' })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') await service.enable(value.id, false) 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() +it('authorizes slot activation and withdrawal without expanding layout or control permissions', async () => { + const { root, store, service, hosts, events } = 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' }], @@ -217,7 +220,17 @@ it('keeps a content slot limited to its selected view surface', async () => { 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') + await hosts[0]!.broker('placements.show', { id: 'tests.footer.slot' }) + await hosts[0]!.broker('placements.hide', { id: 'tests.footer.slot' }) + expect(events).toContainEqual(expect.objectContaining({ kind: 'placement', placementId: 'tests.footer.slot', visible: false })) + await request('view.setActive', { active: false }) + expect(events).toContainEqual(expect.objectContaining({ kind: 'activity', viewId: session.id, token: session.token, active: false })) + await request('view.setActive', { active: true }) + await expect(request('view.setActive', { active: 'yes' })).rejects.toThrow() + await expect(service.viewRequest(session.id, session.generation, randomUUID(), 'view.setActive', { active: false })).rejects.toThrow('EXTENSION_VIEW_EXPIRED') + await expect(hosts[0]!.broker('placements.hide', { id: 'tests.footer.slot', interactionId: randomUUID() })).rejects.toThrow('EXTENSION_PLACEMENT_UNAVAILABLE') + await service.enable(value.id, false) + await expect(request('view.setActive', { active: true })).rejects.toThrow('EXTENSION_VIEW_EXPIRED') }) it('does not let an older concurrent open replace a newer view endpoint', async () => { diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md index 4eb22e0a..1123c831 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md +++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md @@ -32,7 +32,7 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 1. 读取 [协议与清单](references/protocol.md) 和 [完整 API 类型](references/api.d.ts)。这些文件与当前 Lexora 一起分发;不要根据其他产品的插件格式猜测接口。 2. 在当前可写工作区创建独立源码目录,例如 `plugins/<插件名>/`。通过文件工具写入真实文件,保留源码以便后续对话修改。不需要用户另外安装 Node、Bun 或 SDK。 3. 新建独立插件时通过 `lexora_tool_search` 查找 `lexora_plugin_identity`,传入按功能选定的简短英文 `slug`,将返回的 `id`、`author` 和 `engines` 写入 `extension.json`。作者未指定时省略参数,使用已保存的默认署名;明确不署名时传空字符串。作者名支持中文,不要求用户提供英文名、平台账号或唯一名称,也不从系统用户名、Git 配置或目录推断署名。不要自行编造 ID 或继续生成 `local.*`。修改、升级、换电脑维护及分发沿用源码内已有 ID,不重新调用身份生成工具;包括已有的 `local.*`。只有创建独立副本时生成新 ID 并同步内部引用。 -4. 按选定组合声明视图、命令、导航和挂载点,明确每项能力由宿主入口还是视图入口调用;限定页面时使用公开上下文和声明条件。仅声明实际需要的权限;没有对应能力时解释限制,不绕过隔离访问宿主 DOM、内部接口或文件。明确用户第一次如何启动、关闭后如何再次打开;仅声明视图或挂载点不会显示它。现有入口足够时不额外增加设置页。 +4. 按选定组合声明视图、命令、导航和挂载点,明确每项能力由宿主入口还是视图入口调用;限定页面时使用公开上下文和声明条件。仅声明实际需要的权限;没有对应能力时解释限制,不绕过隔离访问宿主 DOM、内部接口或文件。明确用户第一次如何启动、关闭后如何再次打开;普通挂载需要显式打开;内容插槽和替换控件的启用、回退与配置归属按 [内容插槽](references/slots.md) 实现。现有入口足够时不额外增加设置页。 5. 使用自包含 TS/JS 和包内资源。复制 `references/api.d.ts` 为源码目录的 `lexora.d.ts`,仅通过 `import type` 引用。源码清单设置 `format: "source"`。视图导出 `render(context, container)`,需要命令、存储或调度时再添加宿主入口 `activate(context)`。 6. 事件、定时器、动画在关闭时清理,动画结束回收节点、空闲时停止循环。页面使用主题变量;窗口效果保持透明、不阻挡输入,并尊重减少动态效果偏好。 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 326b8849..f1b22410 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 @@ -123,6 +123,7 @@ export interface ViewContext { readonly commands: { execute: (command: string, args?: Json) => Promise } setPresentation: (presentation: ViewPresentation) => Promise setState: (state: Json) => Promise + setActive: (active: boolean) => Promise } export interface ExtensionModule { activate: (context: ExtensionContext) => void | Promise 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 9ea3283a..77a4a3b7 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -96,8 +96,8 @@ | --- | --- | --- | | `decoration` | `anchor` | 自动挂载至 `app.sidebar`、`workbench.sidebar`、`workbench.pane` 或 `composer.input`;每个锚点实例分别挂载,只在该区域内绘制,不接收指针和焦点 | | `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) | +| `control` | `target` | 当前支持 `model.reasoning`;由插件自身配置启停,运行规则见 [内容插槽与替换控件](slots.md) | +| `slot` | `target` | 需要将 `apiVersion` 设为 3;由插件自身配置启停,目标、尺寸和默认内容回退见 [内容插槽](slots.md) | 挂载口只提供容器,不提供面板外壳或交互。声明示意:`{id,view,kind:"view",target:"workbench",presentation:{position:"absolute",width:420,height:180,right:16,bottom:16}}`。目标所属范围由能力目录返回。API 3 的分屏挂载以 `instanceId` 区分实例;从命令 `invocation.instanceId` 传入发起操作的分屏,不要在异步完成后重新选择焦点。省略时使用调用时的活动分屏,标识失效则拒绝打开。每个分屏分别保存状态,分屏移除时结束该实例。挂载口暂时不可用时保留实例,恢复后继续投影。插件自行渲染全部内容、样式与交互;插件之间保留独立实例,宿主不生成切换器、不自动避让或折叠。 @@ -105,7 +105,7 @@ `context.setPresentation({...})` 合并更新上述字段,传 `target` 可转移挂载口。进入分屏区域要求该视图在创建时已绑定分屏,不会把全局实例隐式移入当前焦点分屏。实例、会话与状态保留;插件的定时器、媒体等不会因为换位置而重建。布局值由宿主持久化,业务状态由插件自己保存。`context.mount` / `onMountChange` 提供目标容器宽高、可见性和当前视图的局部矩形,供插件响应尺寸变化或实现拖动等交互,不包含屏幕坐标、其他插件信息或父 DOM。 -宿主不认识“折叠”“展开”“贴边”或“播放器”。需要这些效果时,插件自己渲染触发入口、控制布局并保存自己的状态。缩小视图外框不销毁内容;调用宿主命令中的 `placements.hide` 才关闭实例。插件负责再次打开的入口,可以使用自己的可见 UI、已声明命令或导航。不要依赖宿主替插件提供关闭栏或恢复按钮。 +宿主不认识“折叠”“展开”“贴边”或“播放器”。需要这些效果时,插件自己渲染触发入口、控制布局并保存自己的状态。缩小视图外框不销毁内容;普通挂载调用宿主命令中的 `placements.hide` 才关闭实例。插件负责再次打开的入口,可以使用自己的可见 UI、已声明命令或导航。不要依赖宿主替插件提供关闭栏或恢复按钮。 旧版 `location: workbench.top/workbench.bottom` 与 `height` 在读取时转换为工作台的 static/-1 或 static/1,旧悬浮声明转换为 absolute。新源码使用 target/presentation,不增加业务专用 location。`control.height` 仍为 32–160,默认 64。 @@ -129,7 +129,7 @@ API 3 的 `contributes.menus` 将已有命令放进业务区域的「更多操 `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` 仍表示关闭实例,与条件隐藏不同。 +条件只控制 UI 的可用性,不授予权限、不启动插件,也不阻止插件自身调用命令或后台调度。命令菜单与导航入口随上下文更新。首次不满足条件的视图不激活;已打开视图离开范围时隐藏,挂载面板退出布局占位,实例、状态与会话保留,返回后恢复。控件不匹配时使用内置控件并拒绝旧提交。普通挂载的 `placements.hide` 仍表示关闭实例;slot/control 的运行期启停和视图 active 状态见 [内容插槽](slots.md)。 `context.visible/onVisibilityChange` 表达当前视图实际可见性(包含页面范围、区域隐藏和窗口隐藏)。插件自行决定隐藏时暂停动画、查询或媒体,宿主不推断业务行为。订阅随视图销毁自动释放;插件也可提前调用返回对象的 `dispose()`。受范围限制的功能应验收首次不匹配、进入、离开、返回、子页切换和重启后的条件恢复。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/slots.md b/apps/buddy/service/resources/skills/plugin-creator/references/slots.md index 8322a44c..971d869f 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/slots.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/slots.md @@ -1,9 +1,15 @@ -# 内容插槽 +# 内容插槽与替换控件 -内容插槽由宿主负责位置和布局,插件提供隔离视图。通过 `lexora_plugin_capabilities({kind:"slot"})` 查询当前宿主支持的位置,也可指定 `target` 只读取一个位置。目录返回用途、作用范围、单选或多选策略,以及高度的最小值、最大值和默认值;不要根据 CSS 类名推测目标。 +宿主负责位置、布局、隔离与冲突处理,插件负责配置交互。插件管理页只管理安装、启停、更新、卸载和授权,不提供插件功能或插槽配置。通过 `lexora_plugin_capabilities({kind:"slot"})` 或 `{kind:"control"}` 查询目标、作用范围、单选或多选策略及高度限制;不要根据 CSS 类名推测目标。 -清单使用 API 3,声明 `{id,kind:"slot",target,view,height?}`。`view` 指向本插件 `resource:"none"` 的视图。内容插槽不接受 `presentation`,不通过 `placements.show/hide` 开关,本身不要求权限。 +内容插槽使用 API 3,声明 `{id,kind:"slot",target,view,height?,enabled?}`;替换控件使用 API 2 起的 `kind:"control"`,并声明目标的 controls 权限。`view` 引用本插件 `resource:"none"` 的视图。两者不接受 `presentation`。`enabled` 默认 true;可选功能可声明 false,由插件自己的设置启用。旧清单无需修改即可注册。 -安装后由用户在插件管理的「界面呈现」中选择;单选目标替换内置内容,多选目标按用户选择顺序追加内容。未选、尚未就绪或提供者不可用时显示内置回退;没有内置内容的追加区为空。输入辅助区总高度受宿主限制,超出后滚动。 +宿主入口通过 `context.placements.show(id)` / `hide(id)` 启用或撤回本插件的 slot/control,返回该贡献 ID,不接受 instanceId 或 interactionId。请求只在当前宿主运行期间生效;持久偏好由插件自己的 `storage` 保存,activate 中读取并重新应用。调用成功表示启用状态已应用,不保证当前页面、竞争和内容状态允许显示。配置可以放在插件已有页面、面板或菜单,不要求新增独立设置页。 -每个可见宿主位置拥有独立视图,`context.instanceId` 是所在分屏的不透明标识,可能为空。它可以关联自己发起的命令和分屏挂载,不提供任务身份、历史或输入文字。视图销毁时清理资源;临时内容不使用 `setState`,需要共享业务状态时通过自身宿主命令访问私有存储。 +视图通过 `await context.setActive(false)` 表示自己当前没有可展示内容,true 表示恢复。此状态仅属于当前视图,不修改插件设置,也不销毁 iframe。初次渲染可以先撤回,取得业务状态后再启用;宿主等待 render 完成才认为视图就绪。关闭、无内容或出错时由宿主渲染真正的默认内容,插件不要复制系统提示或样式。 + +同一单选目标按稳定顺序使用首个已启用、符合页面条件、就绪且 active 的视图;多选目标按该顺序最多显示八个。旧版本保存的选择只作为优先顺序保留,其余按贡献 ID 排序。不可用贡献让出位置。`context.visible/onVisibilityChange` 反馈实际显示结果;不要用 visible 反向决定 active,否则隐藏后无法恢复。隐藏视图继续接收本插件的广播,可据新内容调用 setActive(true)。持续失败不自动重试;替换控件可由插件显式关闭再开启,或重启插件后重试。 + +每个宿主位置拥有独立视图,`context.instanceId` 是所在分屏的不透明标识,可能为空;不提供任务身份、历史或输入文字。符合页面条件的视图会初始化,即使贡献尚未启用或被其他提供者占用,以便恢复插件配置和监听内容变化。隐藏时停止不必要的绘制与轮询,保留恢复所需的订阅。 + +内容插槽和替换控件不用 setState;共享业务数据通过自身宿主命令读写 storage,用 views.broadcast 通知自身视图。配置更新需验证即时生效、关闭后恢复默认内容、重新开启和应用重启恢复。视图销毁时清理订阅与资源。 diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index bdf35afb..46b7706f 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -90,6 +90,7 @@ export type ExtensionWorkbenchEvent | { 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: 'activity', requestId: string, viewId: string, generation: string, token: string, active: boolean } | { kind: 'presentation', requestId: string, viewId: string, generation: string, token: string, presentation: WorkbenchPresentation } export const extensionManagementSchema = z.discriminatedUnion('action', [ diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index 370536c7..8c8544cb 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -40,8 +40,8 @@ export const extensionPlacementSchema = z.discriminatedUnion('kind', [ 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().optional() }).strict(), - z.object({ ...placementBase, kind: z.literal('slot'), target: workbenchSlotSchema, height: z.number().int().optional() }).strict(), + z.object({ ...placementBase, kind: z.literal('control'), target: workbenchControlSchema, height: z.number().int().optional(), enabled: z.boolean().optional() }).strict(), + z.object({ ...placementBase, kind: z.literal('slot'), target: workbenchSlotSchema, height: z.number().int().optional(), enabled: z.boolean().optional() }).strict(), ]).transform((placement, context) => { if (placement.kind !== 'slot' && placement.kind !== 'control') return placement diff --git a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts index 81e32675..2f861107 100644 --- a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts +++ b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts @@ -12,7 +12,7 @@ interface ContentDefinition extends TargetDefinition { 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.' }, + '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. Available providers appear in stable 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 @@ -67,7 +67,7 @@ function catalog>(k } 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 workbenchContributionCatalog = [...workbenchUiTargetCatalog.map(target => ({ ...target, description: `${target.description} Plugin-owned settings: enabled defaults to true; placements.show/hide toggles an owned contribution for the current host generation. ViewContext.setActive(false) yields native fallback without destroying the view. First available provider wins a single target; legacy order then contribution ID. See slots.md.` })), ...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) { diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index 1c8b4491..18bfecf1 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -92,8 +92,8 @@ watch(() => pages.context.value.values, (values, _, cleanup) => { const anchors = new SemanticAnchorRegistry() onScopeDispose(() => anchors.dispose()) 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) +useExtensionContributions({ controller: workbench.controller, renderers: workbench.renderers, persistence: workbench.persistence, installed: extensions.installed, api: api.extensions, views: extensionViews, ui, ready: () => workbench.initialized }) useProvideExtensionContext({ authoring: { author: computed(() => stores.applicationSettings.config.value?.desktop.pluginAuthor ?? ''), save: author => stores.applicationSettings.updateSettings({ desktop: { pluginAuthor: author } }) }, 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) } }) diff --git a/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts b/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts index 75075a51..6d8b76e1 100644 --- a/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts +++ b/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts @@ -26,12 +26,12 @@ function setup() { receive = listener return () => {} }, replyWorkbench: (id, view) => replies.set(id, view) } - const views = { surfaces: new Map(), proposeControl: () => false } as unknown as ExtensionViews + const views = { surfaces: new Map(), proposeControl: () => false, retryControl: () => {} } as unknown as ExtensionViews const persistence = { flush: async () => {} } as unknown as WorkbenchPersistence const scope = effectScope() scopes.push(scope) - scope.run(() => useExtensionContributions({ controller, renderers: new ViewRendererRegistry(), persistence, installed, api: api as ExtensionApi, views, ready: () => ready.value }))! const contributions = scope.run(() => useExtensionUiContributions(installed, controller.configuration))! + scope.run(() => useExtensionContributions({ controller, renderers: new ViewRendererRegistry(), persistence, installed, api: api as ExtensionApi, views, ui: contributions, ready: () => ready.value }))! const show = (): Extract => ({ kind: 'placement', requestId: crypto.randomUUID(), extensionId: status.manifest.id, generation: status.generation!, placementId: 'tests.music.dock', visible: true }) return { installed, ready, controller, status, contributions, views, replies, show, receive: (event: ExtensionWorkbenchEvent) => receive(event) } } @@ -166,12 +166,6 @@ it('migrates a pane placement to global scope without retaining the old pane lif expect(restoreWorkbenchLayout(JSON.parse(JSON.stringify(f.controller.layout))).views[id]).toMatchObject({ id, state }) }) -it('restores control selection without relying on unrelated layout changes', () => { - const fixture = setup() - fixture.controller.configuration.restore({ 'workbench.controls.model.reasoning': 'tests.reasoning.control' }) - expect(fixture.contributions.entries.value.find(entry => entry.target === 'model.reasoning')!.selected[0]).toBe('tests.reasoning.control') -}) - it('rejects an obsolete token when a view is reopened in the same host', async () => { const fixture = setup() fixture.ready.value = true @@ -274,3 +268,34 @@ it('exposes plugin short names as namespaces without publisher prefixes or globa await settle() expect([...f.controller.registry.commands.values()].filter(command => command.slash).map(command => command.slash)).toEqual([{ name: 'music:review', description: '检查当前播放列表', origin: { id: 'tests.music', name: 'Music', author: undefined, version: '1.0.0', source: undefined } }]) }) + +it.each(['slot', 'control'] as const)('acknowledges %s activation with a request UUID and keeps it out of persisted layout', async (kind) => { + const f = setup() + const target = kind === 'slot' ? 'composer.footer' : 'model.reasoning' + f.status.manifest = extensionManifestSchema.parse({ + ...f.status.manifest, + apiVersion: 3, + permissions: { controls: ['model.reasoning'] }, + contributes: { ...f.status.manifest.contributes, placements: [{ id: 'tests.music.dock', view: 'tests.music.player', kind, target }] }, + }) + f.installed.value = [{ ...f.status }] + f.ready.value = true + const provider = () => f.contributions.providers(kind === 'slot' ? { kind, target: 'composer.footer' } : { kind, target: 'model.reasoning' })[0]! + const hide = { ...f.show(), visible: false } + f.receive(hide) + await settle() + expect(f.replies.get(hide.requestId)).toBe(hide.requestId) + expect(provider().enabled).toBe(false) + const show = f.show() + f.receive(show) + await settle() + expect(f.replies.get(show.requestId)).toBe(show.requestId) + expect(provider().enabled).toBe(true) + expect(f.controller.layout.views).toEqual({}) + expect(f.controller.configuration.snapshot()).toEqual({}) + const invalid = { ...f.show(), instanceId: crypto.randomUUID(), visible: false } + f.receive(invalid) + await settle() + expect(f.replies.get(invalid.requestId)).toBeNull() + expect(provider().enabled).toBe(true) +}) diff --git a/apps/buddy/src/app/workbench/useExtensionContributions.ts b/apps/buddy/src/app/workbench/useExtensionContributions.ts index 3d7b64ed..b9ebba10 100644 --- a/apps/buddy/src/app/workbench/useExtensionContributions.ts +++ b/apps/buddy/src/app/workbench/useExtensionContributions.ts @@ -1,6 +1,6 @@ import type { ExtensionApi, ExtensionStatus, ExtensionWorkbenchEvent } from '@buddy-shared/extensions/extensionApi' import type { Ref } from 'vue' -import type { ExtensionViews } from '@/modules/extensions' +import type { ExtensionUiContributions, ExtensionViews } from '@/modules/extensions' import type { ViewRendererRegistry } from '@/workbench/browser/ViewRendererRegistry' import type { ViewLocation } from '@/workbench/common/workbench' import type { WorkbenchController } from '@/workbench/services/WorkbenchController' @@ -9,11 +9,11 @@ import { extensionCommandNamespace } from '@buddy-shared/extensions/extensionCom import { spaceFileTargetSchema } from '@buddy-shared/spaces/spaceFileApi' import { qualifyWorkbenchCommand } from '@buddy-shared/workbench/workbenchCommand' import { matchesWorkbenchContext } from '@buddy-shared/workbench/workbenchContext' -import { onScopeDispose, watch } from 'vue' +import { nextTick, onScopeDispose, watch } from 'vue' import { DesktopExtensionView } from '@/modules/extensions/ui' -export function useExtensionContributions(options: { controller: WorkbenchController, renderers: ViewRendererRegistry, persistence: WorkbenchPersistence, installed: Readonly>, api: ExtensionApi, views: ExtensionViews, ready: () => boolean }) { - const { controller, renderers, persistence, installed, api, views } = options +export function useExtensionContributions(options: { controller: WorkbenchController, renderers: ViewRendererRegistry, persistence: WorkbenchPersistence, installed: Readonly>, api: ExtensionApi, views: ExtensionViews, ui: ExtensionUiContributions, ready: () => boolean }) { + const { controller, renderers, persistence, installed, api, views, ui } = options onScopeDispose(renderers.register('extensions.view', DesktopExtensionView)) const owners = new Map void }>() watch(installed, (items) => { @@ -135,10 +135,26 @@ export function useExtensionContributions(options: { controller: WorkbenchContro if (views.proposeControl(event.viewId, event.generation, event.token, event.proposal)) viewId = event.viewId } + else if (event.kind === 'activity') { + if (views.setActive(event.viewId, event.generation, event.token, event.active)) { + await nextTick() + viewId = event.viewId + } + } else if (event.kind === 'placement') { const plugin = installed.value.find(item => item.manifest.id === event.extensionId && item.enabled && item.compatible) const placement = plugin?.manifest.contributes.placements.find(placement => placement.id === event.placementId) const descriptor = plugin?.manifest.contributes.views.find(view => view.id === placement?.view) + if ((placement?.kind === 'slot' || placement?.kind === 'control') && !event.instanceId && !event.interactionId) { + const result = ui.setEnabled(event.extensionId, event.generation, placement.id, event.visible) + if (result) { + if (result.changed && event.visible && placement.kind === 'control') + views.retryControl(placement.id) + await nextTick() + viewId = event.requestId + } + return + } if (placement?.kind !== 'view' || !descriptor) return const instanceId = placement.target === 'workbench.pane' ? event.instanceId ?? controller.layout.activePane : undefined diff --git a/apps/buddy/src/modules/extensions/index.ts b/apps/buddy/src/modules/extensions/index.ts index 714facb5..333a443f 100644 --- a/apps/buddy/src/modules/extensions/index.ts +++ b/apps/buddy/src/modules/extensions/index.ts @@ -1,5 +1,6 @@ export { useProvideExtensionContext } from './extensionContext' export { useExtensionState } from './state/useExtensionState' +export type { ExtensionUiContributions } from './state/useExtensionUiContributions' export { useExtensionUiContributions } from './state/useExtensionUiContributions' export { useExtensionViews } from './widgets/useExtensionViews' export type { ExtensionViews } from './widgets/useExtensionViews' diff --git a/apps/buddy/src/modules/extensions/state/__tests__/useExtensionUiContributions.spec.ts b/apps/buddy/src/modules/extensions/state/__tests__/useExtensionUiContributions.spec.ts index 1ffcc0ea..65a1c8c9 100644 --- a/apps/buddy/src/modules/extensions/state/__tests__/useExtensionUiContributions.spec.ts +++ b/apps/buddy/src/modules/extensions/state/__tests__/useExtensionUiContributions.spec.ts @@ -7,10 +7,10 @@ import { ContributionRegistry } from '@/workbench/services/ContributionRegistry' import { WorkbenchController } from '@/workbench/services/WorkbenchController' import { useExtensionUiContributions } from '../useExtensionUiContributions' -it('retains ordered selections through disabled providers and rejects malformed settings', () => { +it('discovers contributions automatically, preserves legacy ordering and isolates runtime overrides by generation', () => { const manifest = extensionManifestSchema.parse({ schemaVersion: 1, apiVersion: 3, id: 'tests.accessories', name: 'Accessories', version: '1.0.0', engines: { lexora: '*' }, contributes: { views: [{ id: 'tests.accessories.view', title: 'Panel', entry: 'view.js', resource: 'none' }], - placements: ['first', 'second'].map(name => ({ id: `tests.accessories.${name}`, view: 'tests.accessories.view', kind: 'slot', target: 'composer.accessory' })), + placements: ['first', 'second', 'optional'].map(name => ({ id: `tests.accessories.${name}`, view: 'tests.accessories.view', kind: 'slot', target: 'composer.accessory', ...(name === 'optional' ? { enabled: false } : {}) })), } }) const status: ExtensionStatus = { manifest, revision: 'one', enabled: true, compatible: true, development: false, pending: null, state: 'active', generation: crypto.randomUUID(), error: null, activationMs: null, logs: [] } const installed = shallowRef([status]) @@ -19,19 +19,25 @@ it('retains ordered selections through disabled providers and rejects malformed const scope = effectScope() const ui = scope.run(() => useExtensionUiContributions(installed, controller.configuration))! const target = { kind: 'slot', target: 'composer.accessory' } as const - const selected = ['tests.accessories.second', 'tests.accessories.first'] + const ids = () => ui.providers(target).filter(provider => provider.enabled).map(provider => provider.placement.id) try { - ui.choose(target, selected) - expect(ui.selected(target).map(provider => provider.placement.id)).toEqual(selected) - expect(() => controller.configuration.set(workbenchUiSelectionKey(target), 'unparsed')).toThrow() - ui.choose(target, [selected[0]!, selected[0]!]) - expect(ui.selected(target).map(provider => provider.placement.id)).toEqual(selected) + expect(ids()).toEqual(['tests.accessories.first', 'tests.accessories.second']) + const previous = ['tests.accessories.second', 'tests.accessories.first'] + controller.configuration.set(workbenchUiSelectionKey(target), JSON.stringify(previous)) + expect(ids()).toEqual(previous) + expect(ui.setEnabled('tests.other', status.generation!, previous[0]!, false)).toBeNull() + expect(ui.setEnabled(manifest.id, crypto.randomUUID(), previous[0]!, false)).toBeNull() + expect(ui.setEnabled(manifest.id, status.generation!, previous[0]!, false)).toEqual({ changed: true }) + expect(ids()).toEqual([previous[1]]) + expect(ui.setEnabled(manifest.id, status.generation!, 'tests.accessories.optional', true)).toEqual({ changed: true }) + expect(ids()).toEqual([previous[1], 'tests.accessories.optional']) + installed.value = [{ ...status }] + expect(ids()).toEqual([previous[1], 'tests.accessories.optional']) + installed.value = [{ ...status, generation: crypto.randomUUID() }] + expect(ids()).toEqual(previous) + expect(controller.configuration.get(workbenchUiSelectionKey(target))).toBe(JSON.stringify(previous)) installed.value = [{ ...status, enabled: false }] - expect(ui.selected(target)).toEqual([]) - expect(controller.configuration.get(workbenchUiSelectionKey(target))).toBe(JSON.stringify(selected)) - ui.choose(target, [selected[1]!]) - installed.value = [status] - expect(ui.selected(target).map(provider => provider.placement.id)).toEqual([selected[1]]) + expect(ui.providers(target)).toEqual([]) } finally { scope.stop() } }) diff --git a/apps/buddy/src/modules/extensions/state/useExtensionUiContributions.ts b/apps/buddy/src/modules/extensions/state/useExtensionUiContributions.ts index 63e7a650..fdac290b 100644 --- a/apps/buddy/src/modules/extensions/state/useExtensionUiContributions.ts +++ b/apps/buddy/src/modules/extensions/state/useExtensionUiContributions.ts @@ -3,35 +3,41 @@ import type { WorkbenchUiSelectionTarget } from '@buddy-shared/workbench/workben import type { Ref } from 'vue' import type { ConfigurationService } from '@/workbench/services/ConfigurationService' import { parseWorkbenchUiSelection, workbenchUiSelectionKey, workbenchUiTargetCatalog } from '@buddy-shared/workbench/workbenchUi' -import { computed, onScopeDispose, shallowRef } from 'vue' +import { computed, onScopeDispose, shallowReactive, shallowRef, watch } from 'vue' export function useExtensionUiContributions(installed: Readonly>, configuration: ConfigurationService) { const revision = shallowRef(0) + const overrides = shallowReactive(new Map()) onScopeDispose(configuration.subscribe(() => revision.value++)) + watch(installed, (items) => { + for (const [id, override] of overrides) { + const plugin = items.find(item => item.manifest.id === override.extensionId) + if (!plugin?.enabled || !plugin.compatible || plugin.generation !== override.generation || plugin.revision !== override.revision || ['failed', 'blocked'].includes(plugin.state)) + overrides.delete(id) + } + }, { flush: 'sync' }) const entries = computed(() => { void revision.value - return workbenchUiTargetCatalog.map(target => ({ - ...target, - selected: parseWorkbenchUiSelection(configuration.get(workbenchUiSelectionKey(target)), target.selection === 'multiple') ?? [], - providers: installed.value.filter(plugin => plugin.enabled && plugin.compatible) - .flatMap(plugin => plugin.manifest.contributes.placements.flatMap(placement => (placement.kind === 'control' || placement.kind === 'slot') && placement.kind === target.kind && placement.target === target.target ? [{ plugin, placement, title: plugin.manifest.contributes.views.find(view => view.id === placement.view)?.title ?? plugin.manifest.name }] : [])) - .sort((left, right) => left.placement.id.localeCompare(right.placement.id)), - })) + return workbenchUiTargetCatalog.map((target) => { + const previous = parseWorkbenchUiSelection(configuration.get(workbenchUiSelectionKey(target)), target.selection === 'multiple') ?? [] + const rank = (id: string) => previous.includes(id) ? previous.indexOf(id) : previous.length + const providers = installed.value.filter(plugin => plugin.enabled && plugin.compatible) + .flatMap(plugin => plugin.manifest.contributes.placements.flatMap(placement => (placement.kind === 'control' || placement.kind === 'slot') && placement.kind === target.kind && placement.target === target.target ? [{ plugin, placement, enabled: overrides.get(placement.id)?.enabled ?? placement.enabled ?? true }] : [])) + .sort((left, right) => rank(left.placement.id) - rank(right.placement.id) || left.placement.id.localeCompare(right.placement.id)) + return { ...target, providers } + }) }) - function entry(target: WorkbenchUiSelectionTarget) { - return entries.value.find(entry => entry.kind === target.kind && entry.target === target.target)! + function providers(target: WorkbenchUiSelectionTarget) { + return entries.value.find(entry => entry.kind === target.kind && entry.target === target.target)!.providers } - function selected(target: WorkbenchUiSelectionTarget) { - const current = entry(target) - return current.selected.flatMap(id => current.providers.filter(provider => provider.placement.id === id)) + function setEnabled(extensionId: string, generation: string, placementId: string, enabled: boolean) { + const provider = entries.value.flatMap(entry => entry.providers).find(provider => provider.plugin.manifest.id === extensionId && provider.plugin.generation === generation && provider.placement.id === placementId && !['failed', 'blocked'].includes(provider.plugin.state)) + if (!provider) + return null + overrides.set(placementId, { extensionId, generation, revision: provider.plugin.revision, enabled }) + return { changed: provider.enabled !== enabled } } - function choose(target: WorkbenchUiSelectionTarget, ids: string[]): void { - const current = entry(target) - const multiple = current.selection === 'multiple' - const value = multiple ? JSON.stringify(ids) : ids[0] ?? '' - if ((!multiple && ids.length > 1) || !parseWorkbenchUiSelection(value, multiple) || ids.some(id => !current.selected.includes(id) && !current.providers.some(provider => provider.placement.id === id))) - return - configuration.set(workbenchUiSelectionKey(target), value) - } - return { entries, selected, choose } + return { providers, setEnabled } } + +export type ExtensionUiContributions = ReturnType diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionControl.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionControl.vue index 03367573..348dd26f 100644 --- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionControl.vue +++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionControl.vue @@ -1,39 +1,28 @@