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
2 changes: 1 addition & 1 deletion apps/buddy/electron/main/extensions/runtime/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ async function initialize() {
await request('view.ready')
}
catch (error) {
document.querySelector('main').textContent = overlay ? '' : 'This extension view could not be loaded.'
document.querySelector('main').textContent = overlay ? '' : 'This plugin view could not be loaded.'
void request('view.failed', { code: /^EXTENSION_[A-Z_]+$/.test(error?.message) ? error.message : 'EXTENSION_VIEW_FAILED' }).catch(() => {})
}
}
Expand Down
14 changes: 12 additions & 2 deletions apps/buddy/extensions/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Lexora 插件开发

## 术语与范围

**插件**是用户可以安装、启用、禁用、更新和卸载的功能包。代码和协议中的 `Extension`(工作台扩展)是这套插件机制的技术名称,不代表另一类可安装产品;用户界面统一使用“插件”。`extension.json`(插件清单)和 `.lexora-extension`(安装包后缀)保持不变。

**插件视图**是插件提供的界面,可以在资源面板里作为标签页打开,也可以显示为独立页面、工作台控件或装饰。资源面板是展示容器,不是插件的所属模块;一个插件可以提供多个视图,也可以只提供命令、AI 工具或后台动作。关闭标签页不等于禁用插件。

**Agent 内部扩展**是应用向底层 AI 运行时注入工具、策略和事件处理逻辑的机制,与用户安装的工作台插件不同;插件只能通过宿主开放的接口贡献 AI 能力,不能直接注入进程内扩展。

## 开发与安装

插件可以添加命令、上下文视图与独立配置页面。[Lexora Plugins](https://github.com/useLexora/plugins) 保存平铺的插件源码与市场目录;开发工具及创作 Skill 位于独立的 Lexora Plugin SDK。`sdk/index.d.ts` 提供公共类型,无需发布或安装单独的 npm SDK。

也可以直接在 Lexora 对话中输入 `$plugin-creator` 和需求,例如 `$plugin-creator 打字时窗口出现礼花`。内置技能生成源码并使用 Lexora 编译器打包,随后显示安装确认;源码保留在任务工作区,便于继续修改。
Expand Down Expand Up @@ -30,8 +40,8 @@ API 3 还支持原生设置模块、分组和单项,以及带权限的 Agent

API 1 不开放 Node、shell 或任意 Desktop API。文件只读,仅能访问用户选中的文件;HTTPS GET 仅访问已确认的来源,不共享浏览器登录态。`context.storage` 保存私有 JSON,`dataVersion` 改变时入口提供 `migrate(previous, from, to)`,失败保留旧数据。上下文视图可用 `setState` 保存标签状态,独立配置页通过宿主命令保存设置。

更新安装后,当前版本继续运行,点击“重启扩展”应用更新。禁用停止插件,重新启用保留配置;普通卸载保留插件设置、私有 JSON 与上下文视图位置,但移除代码和定时任务。“卸载并清理”还会删除设置、私有数据和视图状态,不删除任务内容或用户文件。
更新安装后,当前版本继续运行,点击“重启插件”应用更新。禁用停止插件,重新启用保留配置;普通卸载保留插件设置、私有 JSON 与上下文视图位置,但移除代码和定时任务。“卸载并清理”还会删除设置、私有数据和视图状态,不删除任务内容或用户文件。

需要独立开发窗口时,先构建 Electron,再运行 `node extensions/tools.mjs dev /path/to/plugin`。开发窗口使用单独的临时配置;修改后重新加载构建目录并重启扩展。SDK 快照可以通过 `node extensions/tools.mjs export-sdk /path/to/plugin-sdk/sdk` 导出。
需要独立开发窗口时,先构建 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)。插件自行提供配置交互,内容区及控件可由插件启停并交还宿主默认内容;具体用法见 [内容插槽](../service/resources/skills/plugin-creator/references/slots.md)。旧版插件继续可用。
4 changes: 2 additions & 2 deletions apps/buddy/src/i18n/locales/en-US/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export default {
'desktop.shortcuts.scope.main': 'Task pane',
'desktop.shortcuts.scope.context': 'Context',

'desktop.settings.category.extensions': 'Workbench extensions',
'desktop.settings.categoryDescription.extensions': 'Install and manage commands and views for the workbench.',
'desktop.settings.category.extensions': 'Plugins',
'desktop.settings.categoryDescription.extensions': 'Install and manage plugins to add commands and views to the workbench.',
'desktop.settings.category.browser': 'Browser',
'desktop.settings.categoryDescription.browser': 'Configure screenshots, zoom, Energy Saver, and browsing data for the built-in browser.',
'desktop.browser.preferences': 'Browsing preferences',
Expand Down
4 changes: 2 additions & 2 deletions apps/buddy/src/i18n/locales/zh-CN/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export default {
'desktop.shortcuts.scope.main': '任务面板',
'desktop.shortcuts.scope.context': '上下文',

'desktop.settings.category.extensions': '工作台扩展',
'desktop.settings.categoryDescription.extensions': '安装和管理工作台的命令与视图扩展。',
'desktop.settings.category.extensions': '插件',
'desktop.settings.categoryDescription.extensions': '安装和管理插件,为工作台添加命令与视图。',
'desktop.settings.category.browser': '浏览器',
'desktop.settings.categoryDescription.browser': '配置内置浏览器的截图、缩放、节能模式与浏览数据。',
'desktop.browser.preferences': '浏览偏好',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import enSettings from '@/i18n/locales/en-US/settings'
import zhSettings from '@/i18n/locales/zh-CN/settings'
import { extensionLabels } from '../extensionLabels'
import { extensionsPage } from '../routes'

const cases = [
{ language: 'zh-CN', settings: zhSettings, labels: { title: '插件', empty: '尚未安装插件', restart: '重启插件', removeTitle: '卸载插件?', loading: '正在加载插件…', failed: '插件视图暂时不可用', retry: '重启插件' } },
{ language: 'en-US', settings: enSettings, labels: { title: 'Plugins', empty: 'No plugins installed', restart: 'Restart plugin', removeTitle: 'Uninstall plugin?', loading: 'Loading plugin…', failed: 'This plugin view is unavailable', retry: 'Restart plugin' } },
] as const

describe('user-facing plugin terminology', () => {
it.each(cases)('uses consistent labels across navigation, settings and management in $language', ({ language, settings, labels }) => {
const actual = extensionLabels(language)
expect(actual).toMatchObject(labels)
expect(extensionsPage.title(language)).toBe(actual.title)
expect(settings['desktop.settings.category.extensions']).toBe(actual.title)
expect(settings['desktop.settings.categoryDescription.extensions']).toMatch(/插件|plugins/)
for (const text of Object.values(actual))
expect(text).not.toMatch(/扩展|\bextensions?\b/i)
})

it('preserves language fallback and bilingual label coverage', () => {
expect(extensionLabels('unknown')).toEqual(extensionLabels('zh-CN'))
expect(Object.keys(extensionLabels('en-US'))).toEqual(Object.keys(extensionLabels('zh-CN')))
})

it('uses plugin terminology in the isolated view failure fallback', () => {
const source = readFileSync(new URL('../../../../electron/main/extensions/runtime/view.js', import.meta.url), 'utf8')
expect(source).toContain('\'This plugin view could not be loaded.\'')
expect(source).not.toContain('This extension view')
expect(source).toContain('channel: \'lexora-extension\'')
expect(source).toContain('\'EXTENSION_VIEW_FAILED\'')
})
})
28 changes: 14 additions & 14 deletions apps/buddy/src/modules/extensions/extensionLabels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const zh = {
title: '工作台扩展',
title: '插件',
marketplace: '插件市场',
installed: '已安装',
marketplaceEmpty: '插件市场尚未开放',
Expand All @@ -13,7 +13,7 @@ const zh = {
creatorUnavailable: '插件创作技能不可用,请在技能设置中启用 plugin-creator。',
acquisitionOptions: '更多插件获取方式',
development: '加载开发目录',
empty: '还没有安装工作台扩展',
empty: '尚未安装插件',
permissions: '申请的权限',
selected: '读取通过命令选中的文件',
network: '访问网站',
Expand All @@ -24,18 +24,18 @@ const zh = {
confirm: '确认安装',
version: '版本',
pending: '待应用版本',
restart: '重启扩展',
restart: '重启插件',
enable: '启用',
disable: '禁用',
remove: '卸载',
removeAndClear: '卸载并清理',
cleanupHint: '“卸载并清理”还会永久删除插件设置、私有数据和视图状态,无法撤销。任务内容和用户文件不受影响。',
removeTitle: '卸载扩展?',
removeTitle: '卸载插件?',
retained: '普通卸载会保留插件设置、私有数据和视图位置,重新安装后可继续使用。',
diagnostics: '运行诊断',
loading: '正在加载扩展…',
failed: '扩展视图暂时不可用',
retry: '重新启动',
loading: '正在加载插件…',
failed: '插件视图暂时不可用',
retry: '重启插件',
dependencies: '依赖',
active: '运行中',
inactive: '按需启动',
Expand All @@ -51,7 +51,7 @@ const zh = {
error: '错误代码',
}
const en: typeof zh = {
title: 'Workbench extensions',
title: 'Plugins',
marketplace: 'Marketplace',
installed: 'Installed',
marketplaceEmpty: 'The marketplace is not available yet',
Expand All @@ -65,7 +65,7 @@ const en: typeof zh = {
creatorUnavailable: 'The plugin creation skill is unavailable. Enable plugin-creator in Skills settings.',
acquisitionOptions: 'More ways to add plugins',
development: 'Load development folder',
empty: 'No workbench extensions installed',
empty: 'No plugins installed',
permissions: 'Requested permissions',
selected: 'Read files selected through commands',
network: 'Access website',
Expand All @@ -76,18 +76,18 @@ const en: typeof zh = {
confirm: 'Confirm installation',
version: 'Version',
pending: 'Pending version',
restart: 'Restart extension',
restart: 'Restart plugin',
enable: 'Enable',
disable: 'Disable',
remove: 'Uninstall',
removeAndClear: 'Uninstall and clear data',
cleanupHint: '“Uninstall and clear data” also permanently deletes plugin settings, private data and view state. This cannot be undone. Tasks and user files are not affected.',
removeTitle: 'Uninstall extension?',
removeTitle: 'Uninstall plugin?',
retained: 'Regular uninstall retains plugin settings, private data and view positions for reinstallation.',
diagnostics: 'Runtime diagnostics',
loading: 'Loading extension…',
failed: 'This extension view is unavailable',
retry: 'Restart',
loading: 'Loading plugin…',
failed: 'This plugin view is unavailable',
retry: 'Restart plugin',
dependencies: 'Dependencies',
active: 'Running',
inactive: 'On demand',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import type { ExtensionViewInput } from '@buddy-shared/extensions/extensionApi'
import { afterEach, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, shallowReactive, shallowRef } from 'vue'
import { extensionLabels } from '../../extensionLabels'
import DesktopExtensionWorkbench from '../../layouts/DesktopExtensionWorkbench.vue'
import DesktopExtensionSurface from '../DesktopExtensionSurface.vue'

const context = {
language: shallowRef('zh-CN'),
state: { api: { restart: vi.fn(async () => {}) } },
views: { surfaces: shallowReactive(new Map<string, { error: string, session: null, eligible: boolean }>()), show: vi.fn(), hide: vi.fn() },
}
vi.mock('../../extensionContext', () => ({ useExtensionContext: () => context }))
vi.mock('@/shared/ui/navigation/DesktopBackToTasksButton.vue', () => ({ default: { render: () => null } }))

const disposables: (() => void)[] = []
afterEach(() => {
disposables.splice(0).forEach(dispose => dispose())
context.views.surfaces.clear()
vi.clearAllMocks()
})

function mount(render: () => ReturnType<typeof h>) {
const element = document.createElement('div')
document.body.append(element)
const app = createApp({ render })
app.mount(element)
disposables.push(() => {
app.unmount()
element.remove()
})
return element
}

it.each(['zh-CN', 'en-US'])('labels the plugin management selector consistently in %s', (language) => {
const element = mount(() => h(DesktopExtensionWorkbench, { language, section: 'installed' }))
const labels = extensionLabels(language)
expect(element.querySelector('[aria-label]')?.getAttribute('aria-label')).toBe(labels.title)
expect(element.textContent).toContain(labels.installed)
})

it.each(['zh-CN', 'en-US'])('shows plugin loading and failure copy and restarts the plugin in %s', async (language) => {
context.language.value = language
const input: ExtensionViewInput = { extensionId: 'tests.reader', viewId: 'reader-view', viewType: 'tests.reader.view', resource: null, state: {}, stateVersion: 1 }
const labels = extensionLabels(language)
const element = mount(() => h(DesktopExtensionSurface, { input, visible: true }))
await nextTick()
expect(element.querySelector('[role="status"]')?.textContent).toContain(labels.loading)

context.views.surfaces.set(input.viewId, { error: 'EXTENSION_VIEW_FAILED', session: null, eligible: true })
await nextTick()
expect(element.querySelector('[role="status"]')?.textContent).toContain(labels.failed)
expect(element.textContent).not.toContain(labels.loading)
const button = element.querySelector('button')!
expect(button.textContent).toContain(labels.restart)
button.click()
expect(context.state.api.restart).toHaveBeenCalledExactlyOnceWith(input.extensionId)
})