Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .playwright/scripts/__tests__/extensionContributions.e2e.mjs
Original file line number Diff line number Diff line change
@@ -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([])
})
93 changes: 93 additions & 0 deletions .playwright/scripts/__tests__/extensionContributions.mjs
Original file line number Diff line number Diff line change
@@ -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 = '<h1>Plugin preferences</h1><label><input type="checkbox"> Enable footer</label><p role="status"></p>'
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 })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/electron/main/extensions/runtime/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。旧版插件继续可用。
24 changes: 20 additions & 4 deletions apps/buddy/platform/extensions/ExtensionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down
Loading