From 7a1bf0ff0c799f25c322971d7968b61c46c20132 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Sun, 27 Sep 2026 20:55:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(buddy):=20=E6=94=AF=E6=8C=81=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=87=AA=E8=A1=8C=E9=85=8D=E7=BD=AE=E6=8F=92=E6=A7=BD?= =?UTF-8?q?=E4=B8=8E=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/extensionContributions.e2e.mjs | 85 +++++++++++++++++ .../__tests__/extensionContributions.mjs | 93 +++++++++++++++++++ .../__tests__/ExtensionCapabilities.spec.ts | 4 +- .../electron/main/extensions/runtime/view.js | 1 + apps/buddy/extensions/README.md | 2 +- .../platform/extensions/ExtensionService.ts | 24 ++++- .../__tests__/ExtensionService.spec.ts | 25 +++-- .../resources/skills/plugin-creator/SKILL.md | 2 +- .../skills/plugin-creator/references/api.d.ts | 1 + .../plugin-creator/references/protocol.md | 8 +- .../skills/plugin-creator/references/slots.md | 16 +++- apps/buddy/shared/extensions/extensionApi.ts | 1 + .../shared/extensions/extensionManifest.ts | 4 +- .../workbench/workbenchContributionCatalog.ts | 4 +- .../src/app/bootstrap/DesktopAppProvider.vue | 2 +- .../useExtensionContributions.spec.ts | 41 ++++++-- .../workbench/useExtensionContributions.ts | 24 ++++- apps/buddy/src/modules/extensions/index.ts | 1 + .../useExtensionUiContributions.spec.ts | 32 ++++--- .../state/useExtensionUiContributions.ts | 50 +++++----- .../widgets/DesktopExtensionControl.vue | 27 ++---- .../widgets/DesktopExtensionManager.vue | 2 - .../widgets/DesktopExtensionSlot.vue | 27 +----- .../widgets/DesktopExtensionUiSettings.vue | 55 ----------- .../__tests__/DesktopExtensionControl.spec.ts | 61 +++++++++--- .../__tests__/DesktopExtensionSlot.spec.ts | 23 +++-- .../widgets/useExtensionUiSurfaces.ts | 30 ++++++ .../extensions/widgets/useExtensionViews.ts | 17 +++- 28 files changed, 465 insertions(+), 197 deletions(-) create mode 100644 .playwright/scripts/__tests__/extensionContributions.e2e.mjs create mode 100644 .playwright/scripts/__tests__/extensionContributions.mjs delete mode 100644 apps/buddy/src/modules/extensions/widgets/DesktopExtensionUiSettings.vue create mode 100644 apps/buddy/src/modules/extensions/widgets/useExtensionUiSurfaces.ts 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 = '