From 6fe2c17cb09d77972895f33044971093839eec78 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Sat, 26 Sep 2026 23:16:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(buddy):=20=E5=AE=8C=E5=96=84=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=90=8C=E5=90=8D=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../electron/main/app/DesktopRuntimeHost.ts | 2 +- .../electron/main/config/LexoraConfigStore.ts | 6 ++ .../__tests__/LexoraConfigStore.spec.ts | 15 ++++ .../__tests__/ExtensionCapabilities.spec.ts | 27 +++++- .../registerExtensionAuthoringRpc.ts | 10 ++- apps/buddy/electron/shared/desktopApi.ts | 1 + .../electron/shared/desktopApiSchemas.ts | 2 + apps/buddy/extensions/README.md | 2 +- apps/buddy/extensions/sdk/authoring.ts | 1 + .../platform/extensions/ExtensionService.ts | 7 +- .../__tests__/ExtensionService.spec.ts | 20 +++-- .../extensions/buildExtensionPackage.ts | 2 +- .../resources/skills/plugin-creator/SKILL.md | 10 ++- .../plugin-creator/references/commands.md | 12 +-- .../plugin-creator/references/interactions.md | 2 +- .../plugin-creator/references/protocol.md | 23 ++--- .../plugin-creator/references/sdk-workflow.md | 2 + .../pluginAuthoringCapability.spec.ts | 24 ++++- .../src/plugins/pluginAuthoringCapability.ts | 34 +++++-- .../__tests__/extensionIdentity.spec.ts | 23 +++++ apps/buddy/shared/extensions/extensionApi.ts | 1 + .../shared/extensions/extensionAuthoring.ts | 6 +- .../shared/extensions/extensionIdentity.ts | 13 +++ .../shared/extensions/extensionManifest.ts | 2 + .../shared/workbench/workbenchCommand.ts | 3 +- .../workbench/workbenchContributionCatalog.ts | 2 +- .../src/app/bootstrap/DesktopAppProvider.vue | 4 +- .../useExtensionContributions.spec.ts | 2 +- .../workbench/useExtensionContributions.ts | 2 +- .../modules/extensions/extensionContext.ts | 1 + .../DesktopExtensionAuthoringSettings.vue | 49 +++++++++++ .../widgets/DesktopExtensionCard.vue | 23 ++--- .../widgets/DesktopExtensionCatalog.vue | 10 ++- .../widgets/DesktopExtensionInstallReview.vue | 7 +- .../widgets/DesktopExtensionManager.vue | 18 ++-- .../__tests__/DesktopExtensionControl.spec.ts | 2 +- .../DesktopExtensionFrameHost.spec.ts | 2 +- .../__tests__/DesktopExtensionSlot.spec.ts | 2 +- .../prompt-input/model/chatComposerInput.ts | 7 +- .../composer/ChatComposerSourcePicker.vue | 8 +- .../widgets/composer/DesktopChatComposer.vue | 1 + .../__tests__/chatComposerEditing.spec.ts | 88 +++++++++++++++++++ .../tasks/widgets/composer/useChatComposer.ts | 22 ++++- .../composer/useChatComposerSuggestions.ts | 21 ++++- .../widgets/composer/useComposerCommands.ts | 11 ++- apps/buddy/src/workbench/common/workbench.ts | 2 +- 46 files changed, 437 insertions(+), 97 deletions(-) create mode 100644 apps/buddy/shared/extensions/__tests__/extensionIdentity.spec.ts create mode 100644 apps/buddy/shared/extensions/extensionIdentity.ts create mode 100644 apps/buddy/src/modules/extensions/widgets/DesktopExtensionAuthoringSettings.vue diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index bef47918..9021f935 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -166,7 +166,7 @@ export class DesktopRuntimeHost { if (!this.inspectExtension) throw new Error('EXTENSION_SERVICE_UNAVAILABLE') return this.inspectExtension(id) - }), + }, () => this.#config?.desktop.pluginAuthor ?? ''), peer.onRequest(contextPanelRpc.presentBrowser, (params) => { const source = contextPanelSourceSchema.parse(params) return this.contextPanel.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness') diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index 1ed5fc97..ec2d44fa 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -7,6 +7,7 @@ import process from 'node:process' import { parse, stringify } from 'smol-toml' import { z } from 'zod' import { browserPreferencesSchema, DEFAULT_BROWSER_PREFERENCES } from '../../../shared/browser/browserPreferences' +import { extensionAuthorSchema } from '../../../shared/extensions/extensionIdentity' import { DEFAULT_PROXY_SETTINGS, proxySettingsSchema } from '../../../shared/network/proxySettings' import { DEFAULT_RUNTIME_PREFERENCES, runtimePreferencesSchema } from '../../../shared/runtime/runtimePreferences' import { keybindingsSchema } from '../../../shared/shortcuts/keybindingSchema' @@ -35,6 +36,7 @@ const taskSidebarConfigSchema = z.object({ }) const desktopConfigSchema = z.object({ + plugin_author: extensionAuthorSchema.default(''), background_close_notice_shown: z.boolean().default(false), chat: z.object({ outline_position: z.enum(DESKTOP_CHAT_OUTLINE_POSITIONS).default(DEFAULT_DESKTOP_CHAT_PREFERENCES.outlinePosition), @@ -66,6 +68,7 @@ const desktopConfigSchema = z.object({ theme: z.enum(['system', 'light', 'dark']).default('system'), }).passthrough().default({ background_close_notice_shown: false, + plugin_author: '', chat: { outline_position: DEFAULT_DESKTOP_CHAT_PREFERENCES.outlinePosition, welcome: DEFAULT_DESKTOP_CHAT_PREFERENCES.welcome, @@ -221,6 +224,7 @@ function decodeConfig(value: unknown): LexoraConfig { proxy: config.proxy, desktop: { backgroundCloseNoticeShown: config.desktop.background_close_notice_shown, + pluginAuthor: config.desktop.plugin_author, chat: { outlinePosition: config.desktop.chat.outline_position, welcome: config.desktop.chat.welcome, @@ -269,6 +273,7 @@ function encodeConfig(config: LexoraConfig) { proxy: config.proxy, desktop: { background_close_notice_shown: config.desktop.backgroundCloseNoticeShown, + plugin_author: config.desktop.pluginAuthor, chat: { outline_position: config.desktop.chat.outlinePosition, welcome: config.desktop.chat.welcome, @@ -312,6 +317,7 @@ function mergeConfig(current: LexoraConfig, patch: LexoraConfigPatch): LexoraCon desktop: { ...current.desktop, ...patch.desktop, + pluginAuthor: extensionAuthorSchema.parse(patch.desktop?.pluginAuthor ?? current.desktop.pluginAuthor), chat: { ...current.desktop.chat, ...patch.desktop?.chat, diff --git a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts index a23e602e..f1a53957 100644 --- a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts +++ b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts @@ -152,6 +152,7 @@ describe('lexoraConfigStore', () => { contextPanelGlobal: false, keybindings: {}, backgroundCloseNoticeShown: false, + pluginAuthor: '', chat: { outlinePosition: 'top-right', welcome: 'writing' }, taskSidebarPinnedItems: [], taskSidebar: { @@ -355,3 +356,17 @@ describe('lexoraConfigStore', () => { }) }) }) + +it('persists an optional Unicode plugin signature without changing profile or previous data on invalid input', async () => { + const { store, configPath } = await createConfigStore() + expect((await store.read()).desktop.pluginAuthor).toBe('') + await store.update({ desktop: { pluginAuthor: '山雨海 · Équipe 🎨', profile: { userName: '个人名称' } } }) + const saved = await readFile(configPath, 'utf8') + const reopened = new LexoraConfigStore({ configPath }) + expect((await reopened.read()).desktop.pluginAuthor).toBe('山雨海 · Équipe 🎨') + await expect(store.update({ desktop: { pluginAuthor: '名'.repeat(81) } })).rejects.toThrow() + expect(await readFile(configPath, 'utf8')).toBe(saved) + await store.update({ desktop: { pluginAuthor: '' } }) + expect((await reopened.read()).desktop.profile.userName).toBe('个人名称') + expect((await reopened.read()).desktop.pluginAuthor).toBe('') +}) diff --git a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts index 8dcf8c6d..0e193234 100644 --- a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts +++ b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts @@ -1,6 +1,6 @@ import type { RuntimeRequestHandler } from '../../../../shared/runtime/rpcPeer' import { expect, it, vi } from 'vitest' -import { EXTENSION_CAPABILITIES_RPC, extensionCapabilitiesSchema } from '../../../../shared/extensions/extensionAuthoring' +import { EXTENSION_CAPABILITIES_RPC, EXTENSION_IDENTITY_RPC, extensionCapabilitiesSchema, extensionIdentitySchema } from '../../../../shared/extensions/extensionAuthoring' import { EXTENSION_API_VERSION, extensionPlacementSchema } from '../../../../shared/extensions/extensionManifest' import { registerExtensionAuthoringRpc } from '../registerExtensionAuthoringRpc' @@ -13,7 +13,7 @@ it('discovers a compact host index and exact target contracts without loading pl const dispose = registerExtensionAuthoringRpc({ onRequest: (method, handler) => { handlers.set(method, handler) return () => handlers.delete(method) - } }, async () => { throw new Error('Unexpected inspection') }) + } }, async () => { throw new Error('Unexpected inspection') }, () => '') try { const query = (params: unknown) => Promise.resolve(handlers.get(EXTENSION_CAPABILITIES_RPC)!(params)) const index = extensionCapabilitiesSchema.parse(await query({})) @@ -31,3 +31,26 @@ it('discovers a compact host index and exact target contracts without loading pl } finally { dispose() } }) + +it('prepares platform-independent identities with explicit or default Unicode signatures', async () => { + const handlers = new Map() + let author = '山雨海' + const dispose = registerExtensionAuthoringRpc({ onRequest: (method, handler) => { + handlers.set(method, handler) + return () => handlers.delete(method) + } }, async () => { throw new Error('Unexpected inspection') }, () => author) + try { + const identity = async (input: unknown) => extensionIdentitySchema.parse(await handlers.get(EXTENSION_IDENTITY_RPC)!(input)) + const first = await identity({ slug: 'music' }) + author = '另一位作者' + const second = await identity({ slug: 'music', author: 'Équipe 🎨' }) + expect(first).toMatchObject({ author: '山雨海', engines: { lexora: '>=0.9.0 <1.0.0' } }) + expect(first.id).toMatch(/^p[a-f0-9]{32}\.music$/) + expect(second.id).not.toBe(first.id) + expect(second.author).toBe('Équipe 🎨') + expect((await identity({ slug: 'music', author: '' })).author).toBe('') + await expect(identity({ slug: 'Music!' })).rejects.toThrow() + await expect(identity({ slug: 'music', author: '名'.repeat(81) })).rejects.toThrow() + } + finally { dispose() } +}) diff --git a/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts b/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts index aa6a1e69..0823f1af 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionAuthoringRpc.ts @@ -1,14 +1,19 @@ import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring' import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import { buildExtensionPackage } from '../../../platform/extensions/buildExtensionPackage' -import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_INSPECT_RPC, extensionCapabilitiesRequestSchema, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring' +import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_IDENTITY_RPC, EXTENSION_INSPECT_RPC, extensionCapabilitiesRequestSchema, extensionIdentityRequestSchema, extensionInspectionSchema, extensionInspectRequestSchema } from '../../../shared/extensions/extensionAuthoring' +import { createExtensionId } from '../../../shared/extensions/extensionIdentity' import { EXTENSION_API_VERSION } from '../../../shared/extensions/extensionManifest' import { queryWorkbenchCapabilities } from '../../../shared/workbench/workbenchUi' import { compileExtension } from './compileExtension' -export function registerExtensionAuthoringRpc(peer: Pick, inspect: (id: string) => Promise): () => void { +export function registerExtensionAuthoringRpc(peer: Pick, inspect: (id: string) => Promise, author: () => string): () => void { const lifetime = new AbortController() let active = 0 + const stopIdentity = peer.onRequest(EXTENSION_IDENTITY_RPC, (input) => { + const request = extensionIdentityRequestSchema.parse(input) + return { id: createExtensionId(request.slug), author: request.author ?? author(), engines: { lexora: '>=0.9.0 <1.0.0' } } + }) const stopInspect = peer.onRequest(EXTENSION_INSPECT_RPC, async input => extensionInspectionSchema.parse(await inspect(extensionInspectRequestSchema.parse(input).id))) const stopCapabilities = peer.onRequest(EXTENSION_CAPABILITIES_RPC, async (input) => { const query = extensionCapabilitiesRequestSchema.parse(input) @@ -29,5 +34,6 @@ export function registerExtensionAuthoringRpc(peer: Pick = z.object({ taskSidebar: taskSidebarPreferencesSchema.optional(), profile: desktopUserProfilePatchSchema.optional(), developerToolsEnabled: z.boolean().optional(), + pluginAuthor: extensionAuthorSchema.optional(), language: z.enum(['zh-CN', 'en-US']).optional(), launchAtLogin: z.boolean().optional(), notificationsEnabled: z.boolean().optional(), diff --git a/apps/buddy/extensions/README.md b/apps/buddy/extensions/README.md index 779799b2..35f81d9d 100644 --- a/apps/buddy/extensions/README.md +++ b/apps/buddy/extensions/README.md @@ -14,7 +14,7 @@ node extensions/tools.mjs pack .output/extensions/my-plugin .output/extensions/m 在「插件 → 安装插件包」中安装生成的包。安装按钮右侧箭头可以加载开发目录;「更多操作 → 安装记录」查看进度、编译错误与取消操作。 -`extension.json` 中的 ID 使用 `发布者.插件名`;命令和视图 ID 以插件 ID 加 `.` 开头。清单声明版本、`engines.lexora`、API 版本、贡献项、依赖与权限。`categories` 和 `tags` 用于市场分类与搜索。 +`extension.json` 中的 `id` 是稳定的插件身份,创建后在修改、升级和发布时持续复用;可选的 `author` 独立保存作者署名,修改署名不改变 ID。命令和视图 ID 以插件 ID 加 `.` 开头。清单声明版本、`engines.lexora`、API 版本、贡献项、依赖与权限。`categories` 和 `tags` 用于市场分类与搜索。 `icon` 可指定包内 SVG、PNG、JPEG 或 WebP 图标(最多 64 KiB),用于导航、插件卡片与安装弹窗。未提供图标时显示默认线框图标。 diff --git a/apps/buddy/extensions/sdk/authoring.ts b/apps/buddy/extensions/sdk/authoring.ts index 3810799d..d40114aa 100644 --- a/apps/buddy/extensions/sdk/authoring.ts +++ b/apps/buddy/extensions/sdk/authoring.ts @@ -1,4 +1,5 @@ export { compileExtensionSource } from '../../platform/extensions/compileExtensionSource' export { extensionIconUrl } from '../../platform/extensions/extensionIcon' +export { createExtensionId, extensionAuthorSchema } from '../../shared/extensions/extensionIdentity' export { extensionManifestSchema, extensionPathSchema } from '../../shared/extensions/extensionManifest' export { queryWorkbenchCapabilities, workbenchCapabilityQuerySchema } from '../../shared/workbench/workbenchUi' diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index 739f1604..d0b9e7d3 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -11,7 +11,6 @@ import { basename } from 'node:path' import { z } from 'zod' import { extensionError, extensionJsonSchema, extensionResourceSchema } from '../../shared/extensions/extensionApi' import { EXTENSION_CATALOG_URL } from '../../shared/extensions/extensionCatalog' -import { extensionCommandNamespace } from '../../shared/extensions/extensionCommands' import { extensionCompatible, extensionManifestSchema } from '../../shared/extensions/extensionManifest' import { extensionDirectoryScanSchema, extensionResourceSelectionSchema } from '../../shared/extensions/extensionResources' import { extensionNotificationSchema, extensionScheduleIdSchema, extensionScheduleInputSchema } from '../../shared/extensions/extensionSchedule' @@ -127,7 +126,7 @@ export class ExtensionService { blocked = extensionError(error) } } - return { manifest: record.current.manifest, iconUrl: await this.store.icon(record.current), revision: record.current.revision, enabled: record.enabled, development: record.development, compatible: extensionCompatible(record.current.manifest, this.store.appVersion), pending: record.pending ? { manifest: record.pending.manifest, revision: record.pending.revision } : null, ...diagnostics, error: blocked ?? diagnostics.error, generation: running?.generation ?? null, state: !record.enabled ? 'disabled' : blocked ? 'blocked' : running ? running.active ? 'active' : 'activating' : diagnostics.error ? 'failed' : 'inactive' } + return { source: record.current.source ?? undefined, manifest: record.current.manifest, iconUrl: await this.store.icon(record.current), revision: record.current.revision, enabled: record.enabled, development: record.development, compatible: extensionCompatible(record.current.manifest, this.store.appVersion), pending: record.pending ? { manifest: record.pending.manifest, revision: record.pending.revision } : null, ...diagnostics, error: blocked ?? diagnostics.error, generation: running?.generation ?? null, state: !record.enabled ? 'disabled' : blocked ? 'blocked' : running ? running.active ? 'active' : 'activating' : diagnostics.error ? 'failed' : 'inactive' } })) } @@ -634,10 +633,6 @@ export class ExtensionService { let running = this.#running.get(dependency) if (!running) { const pkg = this.store.installed[dependency]!.current - if (pkg.manifest.contributes.commands.some(command => command.slash) && [...this.#running.values()].some(other => other.package.manifest.contributes.commands.some(command => command.slash) && extensionCommandNamespace(other.package.manifest.id) === extensionCommandNamespace(dependency))) { - this.#log(dependency, 'activation.failed', 'EXTENSION_COMMAND_NAMESPACE_CONFLICT') - throw new Error('EXTENSION_COMMAND_NAMESPACE_CONFLICT') - } const generation = randomUUID() const abort = new AbortController() let host: ExtensionHost diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts index 6981a73c..b838720a 100644 --- a/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts +++ b/apps/buddy/platform/extensions/__tests__/ExtensionService.spec.ts @@ -443,14 +443,20 @@ it('cancels interaction endpoints when the host stops and keeps broadcasts owned expect(f.events).toContainEqual(expect.objectContaining({ kind: 'interaction', interactionId: id, title: null })) }) -it('rejects a duplicate plugin namespace and releases it when the original host stops', async () => { +it('runs same-name commands in different plugins without sharing handlers or data', async () => { const f = await interactionFixture() - const value = manifest({ id: 'other.game', apiVersion: 3, contributes: { commands: [{ id: 'other.game.run', title: 'Run', slash: { name: 'run' } }] } }) + const value = manifest({ id: 'other.game', apiVersion: 3, contributes: { commands: [{ id: 'other.game.start', title: 'Start', slash: { name: 'game-start' } }] } }) await f.service.install((await reviewPackage(f.root, f.store, value)).token) - await expect(f.service.executeSlash(value.id, 'other.game.run', '')).rejects.toThrow('EXTENSION_COMMAND_NAMESPACE_CONFLICT') - expect((await f.service.list()).find(item => item.manifest.id === 'tests.game')?.state).toBe('active') + await f.service.executeSlash(value.id, 'other.game.start', 'second') + await f.service.executeSlash('tests.game', 'tests.game.start', 'first') + expect((await f.service.list()).filter(item => item.state === 'active').map(item => item.manifest.id).sort()).toEqual(['other.game', 'tests.game']) + expect(f.hosts[0]!.commands.at(-1)).toMatchObject({ command: 'tests.game.start', arguments: 'first' }) + expect(f.hosts[1]!.commands.at(-1)).toMatchObject({ command: 'other.game.start', arguments: 'second' }) + await f.store.saveData('tests.game', { selected: 'first' }, 1) + await f.store.saveData('other.game', { selected: 'second' }, 1) + expect((await f.store.data('tests.game')).value).toEqual({ selected: 'first' }) + expect((await f.store.data('other.game')).value).toEqual({ selected: 'second' }) await f.service.enable('tests.game', false) - await f.service.restart(value.id) - await f.service.executeSlash(value.id, 'other.game.run', '') - expect((await f.service.list()).find(item => item.manifest.id === value.id)?.state).toBe('active') + await f.service.executeSlash(value.id, 'other.game.start', 'still available') + expect(f.hosts[1]!.commands.at(-1)).toMatchObject({ arguments: 'still available' }) }) diff --git a/apps/buddy/platform/extensions/buildExtensionPackage.ts b/apps/buddy/platform/extensions/buildExtensionPackage.ts index 9dead9c0..cf55a8b4 100644 --- a/apps/buddy/platform/extensions/buildExtensionPackage.ts +++ b/apps/buddy/platform/extensions/buildExtensionPackage.ts @@ -35,7 +35,7 @@ export async function buildExtensionPackage(input: unknown, compile: ExtensionCo signal.throwIfAborted() validateExtensionFiles(compiled) const bytes = zipSync(Object.fromEntries(compiled), { level: 6 }) - return { ok: true, id: manifest.id, version: manifest.version, archive: Buffer.from(bytes).toString('base64'), diagnostics } + return { ok: true, id: manifest.id, name: manifest.name, author: manifest.author ?? '', version: manifest.version, archive: Buffer.from(bytes).toString('base64'), diagnostics } } catch (error) { signal.throwIfAborted() diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md index 3ec4d581..4eb22e0a 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md +++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md @@ -15,6 +15,8 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 用户提供外部参考时先阅读公开说明或代码,提取需要复现的用户行为,再映射到 Lexora 的正式能力。参考项目的内部接口不能直接移植;读取失败时说明缺少的依据,不把推测写成已经核对的实现。 +用户给出的作者署名或调用名不符合规则时,说明具体原因并给出 1–3 个保留原意的可用候选,通过对话请用户确定后继续。不要静默截断、转拼音、替换为系统账号或生成陌生名称;中文作者署名本身合法,无需改成英文。尚未指定调用名时可以按插件功能提出简短名称。 + ## 按需求组合能力 读取 [能力选择与组合](references/capabilities.md),将需求对应到输入来源、呈现方式、运行入口与状态归属,再确定最小清单。一个插件可以组合多种正式扩展点,也可以只提供命令或一个文件视图;现有示例不是插件类别或必须复制的模板。 @@ -25,13 +27,17 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 ## 阅读协议并创作 +继续旧对话创作时,重新读取当前技能与协议。历史消息、旧工具结果和压缩摘要中的命名示例不作为新插件的身份依据;维护已有插件时读取实际源码清单,并保留其中的 ID。 + 1. 读取 [协议与清单](references/protocol.md) 和 [完整 API 类型](references/api.d.ts)。这些文件与当前 Lexora 一起分发;不要根据其他产品的插件格式猜测接口。 2. 在当前可写工作区创建独立源码目录,例如 `plugins/<插件名>/`。通过文件工具写入真实文件,保留源码以便后续对话修改。不需要用户另外安装 Node、Bun 或 SDK。 -3. 使用稳定的 `发布者.插件名` ID。未指定发布者时使用 `local`;不要冒用官方发布者。修改已有插件保持原 ID,并提升版本。 +3. 新建独立插件时通过 `lexora_tool_search` 查找 `lexora_plugin_identity`,传入按功能选定的简短英文 `slug`,将返回的 `id`、`author` 和 `engines` 写入 `extension.json`。作者未指定时省略参数,使用已保存的默认署名;明确不署名时传空字符串。作者名支持中文,不要求用户提供英文名、平台账号或唯一名称,也不从系统用户名、Git 配置或目录推断署名。不要自行编造 ID 或继续生成 `local.*`。修改、升级、换电脑维护及分发沿用源码内已有 ID,不重新调用身份生成工具;包括已有的 `local.*`。只有创建独立副本时生成新 ID 并同步内部引用。 4. 按选定组合声明视图、命令、导航和挂载点,明确每项能力由宿主入口还是视图入口调用;限定页面时使用公开上下文和声明条件。仅声明实际需要的权限;没有对应能力时解释限制,不绕过隔离访问宿主 DOM、内部接口或文件。明确用户第一次如何启动、关闭后如何再次打开;仅声明视图或挂载点不会显示它。现有入口足够时不额外增加设置页。 5. 使用自包含 TS/JS 和包内资源。复制 `references/api.d.ts` 为源码目录的 `lexora.d.ts`,仅通过 `import type` 引用。源码清单设置 `format: "source"`。视图导出 `render(context, container)`,需要命令、存储或调度时再添加宿主入口 `activate(context)`。 6. 事件、定时器、动画在关闭时清理,动画结束回收节点、空闲时停止循环。页面使用主题变量;窗口效果保持透明、不阻挡输入,并尊重减少动态效果偏好。 +修改作者署名时直接更新清单 `author`,保留已有 `id` 和所有贡献引用,无需调用身份生成工具。ID 的前缀不会自动成为署名;用户说“用户名”或“发布者”且含义不明确时,先区分作者署名与更换插件身份。用户明确要求更换 ID 时,先说明它会被当作另一份插件,既有数据不会自动转移。 + ## 编译与修正 通过 `lexora_tool_search` 查找 `lexora_plugin_build`,然后调用它: @@ -44,7 +50,7 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 `output` 是已存在父目录中的新文件,放在源码目录之外;现有文件不会被覆盖。工具使用内置隔离编译器,不执行 npm 安装脚本或插件代码。错误结果包含 `code` 与有限的 `diagnostics`:修正对应问题后重新构建,避免重复提交相同错误。需要重新生成包时使用新的输出文件名。 -`review: true` 请求显示安装确认,用户确认后才会安装。只要求源码或打包时设为 `false`。不要绕过确认或通过内部配置安装。构建结果中的 `installation` 只表示是否发起审核,不代表当前安装状态;旧版本返回的 `installed: false` 也不能用于断言用户尚未安装。 +`review: true` 请求显示安装确认,用户确认后才会安装。只要求源码或打包时设为 `false`。不要绕过确认或通过内部配置安装。交付前核对构建结果中的 `id`、`name`、`author` 和 `version`。`author` 为空表示安装界面会显示未署名;用户要求署名时,修正清单并重新构建,不能把 ID 前缀当作署名已经生效。构建结果中的 `installation` 只表示是否发起审核,不代表当前安装状态;旧版本返回的 `installed: false` 也不能用于断言用户尚未安装。 需要判断安装、待应用更新或加载失败时,通过 `lexora_tool_search` 查找 `lexora_plugin_inspect`,以插件 `id` 读取当前宿主状态。比较当前版本与 `pendingVersion`,检查可见命令、导航入口、视图 ready 和错误。仍待用户审核时交付结果即可,不循环轮询;工具不可用时明确安装状态未核实。ready 只证明入口完成加载,不能代替实际功能验收。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/commands.md b/apps/buddy/service/resources/skills/plugin-creator/references/commands.md index 5a24ad64..37af6a2b 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/commands.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/commands.md @@ -2,13 +2,15 @@ API 3 可将既有命令公开为输入框 `/插件名:命令`。先声明,再在 `activate` 中通过 `context.commands.register(id, handler)` 注册;命令面板、菜单、视图调用与 Slash 共用同一处理函数。 +`` 使用 [清单](protocol.md#清单) 中确定的实际 ID。 + ```json -{ "id": "local.example.start", "title": "开始", "slash": { "name": "start", "description": "开始一次交互" } } +{ "id": ".start", "title": "开始", "slash": { "name": "start", "description": "开始一次交互" } } ``` ```ts export function activate(context: ExtensionContext) { - context.commands.register('local.example.start', async ({ arguments: args, invocation }) => { + context.commands.register(`${context.extension.id}.start`, async ({ arguments: args, invocation }) => { if (invocation?.target === 'slash') { // args 是用户显式输入的参数字符串,instanceId 是发起分屏。 await start(args as string, invocation.instanceId) @@ -17,13 +19,13 @@ export function activate(context: ExtensionContext) { } ``` -名称是插件内的本地名称,宿主使用插件 ID 中发布者后面的名称作为命名空间,例如 `local.example` 的命令为 `/example:start`。中文显示名称不参与匹配。顶级 `/review`、`/compact`、`/skills`、`/status` 等只归系统所有;插件可声明本地 `review`,但只能通过 `/example:review` 调用,不生成顶级短别名。 +名称是插件内的本地名称,宿主使用插件 ID 最后一个点后面的稳定调用名作为命名空间,例如 ID 末段为 `example` 时,命令为 `/example:start`。中文显示名称不参与匹配。顶级 `/review`、`/compact`、`/skills`、`/status` 等只归系统所有;插件可声明本地 `review`,但只能通过 `/example:review` 调用,不生成顶级短别名。 -本地命令名长度为 1–64,匹配 `[a-z][a-z0-9-]*`:英文小写字母开头,后续允许数字和连字符。`title` 和 `description` 用于中文说明。包内本地名称唯一,隐藏命令不能声明 Slash;非法清单在编译或安装时拒绝,不自动修正或改名。不同插件命名空间内可使用同一命令名;如果两个发布者使用相同插件名并都提供 Slash,后启动的插件报命名空间冲突,需修改插件名。 +本地命令名长度为 1–64,匹配 `[a-z][a-z0-9-]*`:英文小写字母开头,后续允许数字和连字符。`title` 和 `description` 用于中文说明。包内本地名称唯一,隐藏命令不能声明 Slash;非法清单在编译或安装时拒绝,不自动修正或改名。不同插件允许相同调用名并同时运行。输入候选展示插件名称和作者,选中后绑定稳定命令 ID;纯文本有多个匹配时保留输入并让用户选择,不能默认执行第一个,也不要求安装时改名。 每个声明必须在 `activate` 完成前注册一次。未知 ID、重复注册、非函数处理器或缺少注册都会使该插件启动失败;捕获注册错误也不能使错误插件启动。修正后显式重启。 -候选按前缀显示;选中只插入命令,确认发送才执行。执行严格匹配完整名称和大小写,参数保持为字符串,不自动解析引号、JSON 或自然语言。插件本地命令不依赖模型配置,可以在任务流式输出时执行。只传命令参数与发起分屏,不传输入框附件、引用、会话身份或历史;带这些内容时拒绝执行并保留草稿。命令不可用或失败时也保留草稿,不降级为模型提示。 +插件管理卡片只提供管理和独立页面入口,不提供通用命令按钮。依赖分屏、文件或输入内容的命令应在声明中用 `when` 限定对应场景,并在处理函数中校验需要的上下文。候选按前缀显示;选中只插入命令,确认发送才执行。执行严格匹配完整名称和大小写,参数保持为字符串,不自动解析引号、JSON 或自然语言。插件本地命令不依赖模型配置,可以在任务流式输出时执行。只传命令参数与发起分屏,不传输入框附件、引用、会话身份或历史;带这些内容时拒绝执行并保留草稿。命令不可用或失败时也保留草稿,不降级为模型提示。 `invocation.target` 为 `slash`,`resource` 为 null,`arguments` 为最多 8192 字符的参数字符串。命令面板调用仍提供既有资源语义且参数为空;视图调用的参数仍为 JSON。插件应按实际调用入口验证自己的参数。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/interactions.md b/apps/buddy/service/resources/skills/plugin-creator/references/interactions.md index 16096c1d..9182bbd5 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/interactions.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/interactions.md @@ -16,7 +16,7 @@ API 3 提供真实布局快照和可取消的交互会话。先查询 `lexora_pl const interaction = await context.interactions.start('互动游戏') interaction.signal.addEventListener('abort', cleanup, { once: true }) try { - await context.placements.show('local.example.game', { + await context.placements.show(`${context.extension.id}.game`, { interactionId: interaction.id, instanceId: originPaneId, }) 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 f4752bfc..9ea3283a 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -10,18 +10,21 @@ { "schemaVersion": 1, "format": "source", - "id": "local.my-plugin", + "id": "", "name": "插件名称", + "author": "作者署名", "description": "插件完成的用户行为。", "version": "1.0.0", - "apiVersion": 2, - "engines": { "lexora": ">=0.8.7 <1.0.0" }, + "apiVersion": 3, + "engines": { "lexora": ">=0.9.0 <1.0.0" }, "permissions": {}, "contributes": {} } ``` -可选字段:`icon`(包内 SVG/PNG/JPEG/WebP,≤64 KiB)、`categories`、`tags`、宿主 `entry`、`dataVersion`。不要添加未定义的字段。命令、视图和挂载声明的 ID 以插件 ID 加 `.` 开头且不重复。文件路径相对包根,不能包含 `..` 或符号链接;512 文件、单文件 4 MiB、合计 16 MiB 上限。 +`` 是占位符,填写时必须替换为新插件身份工具返回的 ID,或正在维护的源码清单中的既有 ID;后续声明中的占位符同样替换。宿主代码通过 `context.extension.id` 引用当前插件,不复制示例身份。作者署名 `author` 可选,支持 Unicode,最多 80 个用户可见字符,不含换行或控制字符;留空表示未署名。不符合规则时说明原因,给出候选并通过对话确定,不静默截断或替换。作者与显示名可变,ID 和末尾的调用名保持稳定。使用作者字段的包要求 Lexora 0.9.0 或更新版本。 + +可选字段:`author`、`icon`(包内 SVG/PNG/JPEG/WebP,≤64 KiB)、`categories`、`tags`、宿主 `entry`、`dataVersion`。不要添加未定义的字段。命令、视图和挂载声明的 ID 以插件 ID 加 `.` 开头且不重复。文件路径相对包根,不能包含 `..` 或符号链接;512 文件、单文件 4 MiB、合计 16 MiB 上限。 `contributes` 内的声明结构如下。省略不用的项;每项权限和运行方式见后文,无需查找应用实现或复制某个示例清单。 @@ -47,7 +50,7 @@ 装饰、替换控件和普通挂载面板的 `container` 默认填满隔离视图,无内边距,支持子元素使用百分比高度。装饰的绘制范围仍受锚点及祖先裁剪约束;页面保持普通文档流,可按内容滚动。 -导航入口位于自动化下方,声明 `contributes.navigation: { "title": "插件名称", "view": "local.plugin.settings" }`,引用一个 `resource: "none"` 的视图(API 1 仍需 `location: "page"`)。不需要设置页的效果插件可以没有导航。 +导航入口位于自动化下方,声明 `contributes.navigation: { "title": "插件名称", "view": ".settings" }`,引用一个 `resource: "none"` 的视图(API 1 仍需 `location: "page"`)。不需要设置页的效果插件可以没有导航。 导航页面、装饰、内容插槽和替换控件不能使用 `context.setState()`;需要持久设置时声明宿主入口和隐藏命令,以 `context.storage.get/set` 保存。多个视图使用同一设置时明确刷新时机,插件局部开关必须能在不重载视图的情况下关闭并重新启用。资源页签的 `setState` 用于保存该页签的 JSON 状态。 @@ -114,7 +117,7 @@ ## 操作菜单 -API 3 的 `contributes.menus` 将已有命令放进业务区域的「更多操作」。先用 `lexora_plugin_capabilities({kind:"menu"})` 查询目标与输入契约,再声明 `{id,command,target,order?,when?}`。`hidden` 仅隐藏命令面板与管理卡片入口,不隐藏显式声明的菜单。 +API 3 的 `contributes.menus` 将已有命令放进业务区域的「更多操作」。先用 `lexora_plugin_capabilities({kind:"menu"})` 查询目标与输入契约,再声明 `{id,command,target,order?,when?}`。`hidden` 隐藏命令面板入口,不隐藏显式声明的菜单。 命令参数中的 `invocation` 包含 `target`、`instanceId`,仅在声明 `selectedContent` 且用户点击对应操作后附带 `content`。文件菜单通过原有 `selectedResource` 权限提供 `resource` 句柄。视图调用自己的命令时 `invocation.target` 为 `view`,只附带该视图的实例标识;命令面板和定时调用的 `invocation` 为空。 @@ -162,19 +165,19 @@ API 3 的 `contributes.menus` 将已有命令放进业务区域的「更多操 import type { ExtensionContext } from './lexora' export function activate(context: ExtensionContext) { - context.subscriptions.add(context.commands.register('local.example.read-settings', async () => context.storage.get())) + context.subscriptions.add(context.commands.register(`${context.extension.id}.read-settings`, async () => context.storage.get())) } ``` -该命令须出现在 `contributes.commands` 中,例如 `{ "id":"local.example.read-settings", "title":"读取设置", "hidden":true }`。视图通过 `context.commands.execute(command, arguments)` 调用自身命令,返回 JSON。私有数据使用 JSON;更改结构时递增 `dataVersion` 并导出 `migrate(previous, from, to)`。 +该命令须出现在 `contributes.commands` 中,例如 `{ "id":".read-settings", "title":"读取设置", "hidden":true }`。视图通过 `context.commands.execute(command, arguments)` 调用自身命令,返回 JSON。私有数据使用 JSON;更改结构时递增 `dataVersion` 并导出 `migrate(previous, from, to)`。 文件工具需要一个可见命令作为打开入口。用户先在文件面板打开文件,再从命令面板执行它;宿主将收到的资源句柄传入视图: ```ts -context.subscriptions.add(context.commands.register('local.example.open', async ({ resource }) => { +context.subscriptions.add(context.commands.register(`${context.extension.id}.open`, async ({ resource }) => { if (!resource) throw new Error('请先打开一个文件') - await context.views.open('local.example.reader', { resource, state: {} }) + await context.views.open(`${context.extension.id}.reader`, { resource, state: {} }) })) ``` diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/sdk-workflow.md b/apps/buddy/service/resources/skills/plugin-creator/references/sdk-workflow.md index a6933bcc..da684ecf 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/sdk-workflow.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/sdk-workflow.md @@ -17,3 +17,5 @@ pnpm run package:source <插件仓库> 在 Lexora 的「插件 → 安装插件包」选择输出包并确认权限。只有用户要求发布时才同步远端。 离线开发可从 SDK 的 `authoring.mjs` 导入 `queryWorkbenchCapabilities` 查询该 SDK 快照支持的目标;它不代表用户已安装宿主的版本。应用内优先查询 `lexora_plugin_capabilities`。 + +新建插件从 `authoring.mjs` 导入 `createExtensionId`,传入简短调用名(例如 `my-plugin`),将结果保存到清单并持续复用。独立 `author` 字段保存可选署名,支持中文;使用该字段时 `engines.lexora` 至少要求 `>=0.9.0 <1.0.0`。不要为重新打包或发布生成新身份。 diff --git a/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts b/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts index 878d5e2e..77514349 100644 --- a/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts +++ b/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts @@ -23,6 +23,7 @@ async function fixture() { await writeFile(join(source, 'view.ts'), 'export function render(_context: unknown, container: HTMLElement) { container.textContent = "Hello" }') const notifications: unknown[] = [] let tool: ToolDefinition | undefined + let identityTool: ToolDefinition | undefined const capability = createPluginAuthoringCapability({ conversationId: 'task', cwd: root, executionProfile: 'workspace_write', getRunId: () => 'run', grants: [{ kind: 'workspace', canonicalRoot: root, root, grantId: 'workspace' }], sessionMode: 'interactive', signal: new AbortController().signal }, { request: async (_method, input, _timeout, signal) => buildExtensionPackage(input, async (files, manifest, _signal, report) => compileExtensionSource(files, manifest, report), signal!), notify: (_method, input) => { @@ -31,12 +32,15 @@ async function fixture() { }) await capability.extension.factory({ registerTool(value: ToolDefinition) { tool = value + if (value.name === 'lexora_plugin_identity') + identityTool = value }, on() {} } as never) return { root, source, notifications, capability, + identity: async (input: unknown) => await identityTool!.execute('identity', input as never, undefined, undefined, {} as never), execute: async (input: unknown) => await tool!.execute('call', input as never, undefined, undefined, {} as never) as { details: { ok: boolean, code?: string, packagePath?: string, installed?: boolean, runtimeTested?: boolean } }, } } @@ -45,7 +49,7 @@ it('writes an authorized installable output and requests review without installi const f = await fixture() const output = join(f.root, 'example.lexora-extension') const result = await f.execute({ source: 'source', output: 'example.lexora-extension', review: true }) - expect(result.details).toMatchObject({ ok: true, packagePath: output, installation: 'review_requested', runtimeTested: false }) + expect(result.details).toMatchObject({ ok: true, id: 'local.example', name: 'Example', author: '', version: '1.0.0', packagePath: output, installation: 'review_requested', runtimeTested: false }) expect(unpackExtension(await readFile(output)).has('view.js')).toBe(true) expect(f.notifications).toEqual([{ path: output }]) }) @@ -68,3 +72,21 @@ it('rejects symlinked source files instead of packaging unrelated user data', as expect((await f.execute({ source: 'source', output: 'bad.lexora-extension', review: true })).details).toMatchObject({ ok: false, code: 'EXTENSION_UNSAFE_PATH' }) expect(f.notifications).toEqual([]) }) + +it('reports invalid author fields and asks for a user choice without substituting an identity', async () => { + const f = await fixture() + const result = await f.identity({ slug: 'music', author: '名'.repeat(81) }) + expect(result.details).toMatchObject({ ok: false, code: 'EXTENSION_IDENTITY_INVALID', diagnostics: [expect.stringContaining('author:')], nextAction: expect.stringContaining('ask the user') }) + expect(result.details).not.toHaveProperty('id') +}) + +it('returns the explicit signature and keeps the existing identity when rebuilding', async () => { + const f = await fixture() + const manifestPath = join(f.source, 'extension.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + await writeFile(manifestPath, JSON.stringify({ ...manifest, author: '山雨海', engines: { lexora: '>=0.9.0 <1' } })) + const result = await f.execute({ source: 'source', output: 'signed.lexora-extension' }) + expect(result.details).toMatchObject({ ok: true, id: 'local.example', name: 'Example', author: '山雨海', version: '1.0.0' }) + const files = unpackExtension(await readFile(join(f.root, 'signed.lexora-extension'))) + expect(JSON.parse(new TextDecoder().decode(files.get('extension.json')))).toMatchObject({ id: 'local.example', author: '山雨海' }) +}) diff --git a/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts b/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts index d6c24b06..33d9f24d 100644 --- a/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts +++ b/apps/buddy/service/src/plugins/pluginAuthoringCapability.ts @@ -9,13 +9,18 @@ import { Type } from 'typebox' import { Check } from 'typebox/value' import { readExtensionDirectory } from '../../../platform/extensions/extensionFiles' import { containsCanonicalPath } from '../../../platform/filesystem/filePaths' -import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_INSPECT_RPC, EXTENSION_REVIEW_REQUEST, extensionBuildResultSchema, extensionCapabilitiesSchema, extensionInspectionSchema } from '../../../shared/extensions/extensionAuthoring' +import { EXTENSION_BUILD_RPC, EXTENSION_CAPABILITIES_RPC, EXTENSION_IDENTITY_RPC, EXTENSION_INSPECT_RPC, EXTENSION_REVIEW_REQUEST, extensionBuildResultSchema, extensionCapabilitiesSchema, extensionIdentityRequestSchema, extensionIdentitySchema, extensionInspectionSchema } from '../../../shared/extensions/extensionAuthoring' import { workbenchCapabilityKinds } from '../../../shared/workbench/workbenchContributionCatalog' import { resolveGrantedPath } from '../directories/resolveGrantedPath' const name = 'lexora_plugin_build' const inspectName = 'lexora_plugin_inspect' const capabilitiesName = 'lexora_plugin_capabilities' +const identityName = 'lexora_plugin_identity' +const identityParameters = Type.Object({ + slug: Type.String({ minLength: 1, maxLength: 64, pattern: '^[a-z][a-z0-9-]*$', description: 'A short readable plugin command namespace, chosen from its purpose. This is not an author name and need not be unique.' }), + author: Type.Optional(Type.String({ maxLength: 1024, description: 'Optional author display name, at most 80 visible characters. Unicode is supported. Omit to use the saved default; empty means unsigned. Do not infer from OS or Git accounts.' })), +}, { additionalProperties: false }) const capabilitiesParameters = Type.Object({ kind: Type.Optional(Type.Union(workbenchCapabilityKinds.map(kind => Type.Literal(kind)))), target: Type.Optional(Type.String({ minLength: 1, maxLength: 100, description: 'Exact target from the host catalog. Omit both filters for a compact index; specify either for detailed contracts.' })), @@ -30,6 +35,8 @@ const parameters = Type.Object({ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, peer: Pick): BuddyCapability { return { classify(event) { + if (event.toolName === identityName) + return Check(identityParameters, event.input) ? { access: 'read', paths: [] } : { blocked: true, reason: 'VALIDATION_FAILED' } if (event.toolName === capabilitiesName) return Check(capabilitiesParameters, event.input) ? { access: 'read', paths: [] } : { blocked: true, reason: 'VALIDATION_FAILED' } if (event.toolName === inspectName) @@ -40,10 +47,27 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, return { blocked: true, reason: 'VALIDATION_FAILED' } return { access: 'write', paths: [{ path: event.input.source, mode: 'existing' }, { path: event.input.output, mode: 'create' }] } }, - disclosure: { group: 'plugins', keywords: 'plugin extension build create inspect capabilities 插件 创建 编译 校验 安装 诊断 插槽', toolNames: [name, inspectName, capabilitiesName] }, + disclosure: { group: 'plugins', keywords: 'plugin extension build create identity author inspect capabilities 插件 创建 身份 作者 编译 校验 安装 诊断 插槽', toolNames: [name, inspectName, capabilitiesName, identityName] }, extension: { name: 'lexora-plugin-authoring', factory(pi) { + pi.registerTool(defineTool({ + name: identityName, + label: 'Prepare plugin identity', + parameters: identityParameters, + description: 'Generate a new stable plugin ID and read the default author signature. Call only when creating a new independent plugin, then save the returned identity and engine requirement into extension.json. For edits or rebuilding, reuse the existing manifest ID. If a supplied author or slug is invalid, explain the rule and ask the user to choose a suitable name; never silently replace it. Does not write files, install or authenticate an author.', + async execute(_toolCallId, input, signal) { + const parsed = extensionIdentityRequestSchema.safeParse(input) + if (!parsed.success) + return response({ ok: false, code: 'EXTENSION_IDENTITY_INVALID', diagnostics: parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`), nextAction: 'Explain the invalid fields and ask the user to choose suitable names before retrying. Do not silently replace or truncate them.' }) + try { + const abort = signal ? AbortSignal.any([signal, context.signal]) : context.signal + const identity = extensionIdentitySchema.parse(await peer.request(EXTENSION_IDENTITY_RPC, parsed.data, 10000, abort)) + return response({ ok: true, ...identity }) + } + catch { return response({ ok: false, code: 'EXTENSION_IDENTITY_FAILED' }) } + }, + })) pi.registerTool(defineTool({ name: capabilitiesName, label: 'Discover plugin capabilities', @@ -76,7 +100,7 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, name, label: 'Build plugin', parameters, - description: 'Validate and compile a self-contained Lexora TS/JS plugin using the isolated built-in compiler, and write an installable package. Returns diagnostics for repair. Does not run plugin code, install dependencies or install plugins. Optionally opens the user installation review.', + description: 'Validate and compile a self-contained Lexora TS/JS plugin using the isolated built-in compiler, and write an installable package. Returns the actual id, name, author (empty means unsigned), version and diagnostics. Author signature is the manifest author field, independent of the ID prefix; changing an author must not regenerate or rename the ID. Does not run plugin code, install dependencies or install plugins. Optionally opens the user installation review.', async execute(toolCallId, input, signal) { const diagnostics: string[] = [] try { @@ -114,7 +138,7 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, finally { await handle.close() } if (input.review) peer.notify(EXTENSION_REVIEW_REQUEST, { path: output.canonicalPath }) - return response({ ok: true, id: result.id, version: result.version, packagePath: output.canonicalPath, diagnostics, reviewRequested: input.review === true, installation: input.review ? 'review_requested' : 'not_requested', runtimeTested: false }) + return response({ ok: true, id: result.id, name: result.name, author: result.author, version: result.version, packagePath: output.canonicalPath, diagnostics, reviewRequested: input.review === true, installation: input.review ? 'review_requested' : 'not_requested', runtimeTested: false }) } catch (error) { const code = (error as { code?: string }).code ?? (error instanceof Error ? error.message : '') @@ -123,7 +147,7 @@ export function createPluginAuthoringCapability(context: BuddyCapabilityContext, }, })) pi.on('tool_result', (event) => { - if ([name, inspectName, capabilitiesName].includes(event.toolName) && event.details && typeof event.details === 'object' && 'ok' in event.details && event.details.ok === false) + if ([name, inspectName, capabilitiesName, identityName].includes(event.toolName) && event.details && typeof event.details === 'object' && 'ok' in event.details && event.details.ok === false) return { isError: true } }) }, diff --git a/apps/buddy/shared/extensions/__tests__/extensionIdentity.spec.ts b/apps/buddy/shared/extensions/__tests__/extensionIdentity.spec.ts new file mode 100644 index 00000000..bea97da3 --- /dev/null +++ b/apps/buddy/shared/extensions/__tests__/extensionIdentity.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { createExtensionId, extensionAuthorSchema } from '../extensionIdentity' +import { extensionManifestSchema } from '../extensionManifest' + +const manifest = { schemaVersion: 1, format: 'source', apiVersion: 3, name: '音乐', version: '1.0.0', engines: { lexora: '>=0.9.0 <1' }, contributes: {} } + +describe('plugin identity and signature', () => { + it('accepts shared display names while preserving independent IDs and legacy manifests', () => { + const first = extensionManifestSchema.parse({ ...manifest, id: createExtensionId('music'), author: '山雨海' }) + const second = extensionManifestSchema.parse({ ...manifest, id: createExtensionId('music'), author: '山雨海' }) + expect(first.id).not.toBe(second.id) + expect(extensionManifestSchema.parse({ ...first, author: '新的署名', name: '新名称' }).id).toBe(first.id) + expect(extensionManifestSchema.parse({ ...manifest, id: 'local.music' }).author).toBeUndefined() + }) + + it.each(['山雨海', 'Équipe 🎨', '👨‍👩‍👧‍👦'.repeat(80), 'e\u0301'.repeat(80), ''])('preserves the Unicode signature %s', (author) => { + expect(extensionAuthorSchema.parse(author)).toBe(author) + }) + + it.each(['名'.repeat(81), 'first\nsecond', 'name\u0000', 'name\u202E', ' name'])('rejects invalid signatures without truncation', (author) => { + expect(extensionAuthorSchema.safeParse(author).success).toBe(false) + }) +}) diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index d42f204d..bdf35afb 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -56,6 +56,7 @@ export interface ExtensionLog { durationMs?: number } export interface ExtensionStatus { + source?: ExtensionReview['source'] manifest: ExtensionManifest iconUrl?: string revision: string diff --git a/apps/buddy/shared/extensions/extensionAuthoring.ts b/apps/buddy/shared/extensions/extensionAuthoring.ts index 8c90a642..7a203d1b 100644 --- a/apps/buddy/shared/extensions/extensionAuthoring.ts +++ b/apps/buddy/shared/extensions/extensionAuthoring.ts @@ -1,12 +1,16 @@ import { z } from 'zod' import { workbenchCapabilityKinds } from '../workbench/workbenchContributionCatalog' import { workbenchCapabilityQuerySchema } from '../workbench/workbenchUi' +import { extensionAuthorSchema, extensionSlugSchema } from './extensionIdentity' import { extensionIdSchema, extensionVersionSchema } from './extensionManifest' export const EXTENSION_BUILD_RPC = 'extensions.build' export const EXTENSION_REVIEW_REQUEST = 'extensions.reviewPackage' export const EXTENSION_INSPECT_RPC = 'extensions.inspect' export const EXTENSION_CAPABILITIES_RPC = 'extensions.capabilities' +export const EXTENSION_IDENTITY_RPC = 'extensions.identity' +export const extensionIdentityRequestSchema = z.object({ slug: extensionSlugSchema, author: extensionAuthorSchema.optional() }).strict() +export const extensionIdentitySchema = z.object({ id: extensionIdSchema, author: extensionAuthorSchema, engines: z.object({ lexora: z.string() }).strict() }).strict() export { workbenchCapabilityQuerySchema as extensionCapabilitiesRequestSchema } export const extensionCapabilitiesSchema = z.object({ apiVersion: z.number().int().positive(), @@ -39,7 +43,7 @@ export type ExtensionInspection = z.infer export const extensionArchiveSchema = z.string().max(24 * 1024 * 1024).regex(/^[A-Z0-9+/]+={0,2}$/i) export const extensionBuildRequestSchema = z.object({ archive: extensionArchiveSchema }).strict() export const extensionBuildResultSchema = z.discriminatedUnion('ok', [ - z.object({ ok: z.literal(true), archive: extensionArchiveSchema, id: z.string(), version: z.string(), diagnostics: z.array(z.string().max(600)).max(30) }).strict(), + z.object({ ok: z.literal(true), archive: extensionArchiveSchema, id: extensionIdSchema, name: z.string().min(1).max(100), author: extensionAuthorSchema, version: extensionVersionSchema, diagnostics: z.array(z.string().max(600)).max(30) }).strict(), z.object({ ok: z.literal(false), code: z.string(), diagnostics: z.array(z.string().max(600)).max(30) }).strict(), ]) export type ExtensionBuildResult = z.infer diff --git a/apps/buddy/shared/extensions/extensionIdentity.ts b/apps/buddy/shared/extensions/extensionIdentity.ts new file mode 100644 index 00000000..4762d8a2 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionIdentity.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +const characters = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +export const EXTENSION_AUTHOR_MAX_LENGTH = 80 +export const extensionAuthorSchema = z.string().max(1024).refine(value => + value === value.trim() + && !/[\p{Cc}\p{Zl}\p{Zp}\u202A-\u202E\u2066-\u2069]/u.test(value) + && [...characters.segment(value)].length <= EXTENSION_AUTHOR_MAX_LENGTH, 'Expected a single-line author name of at most 80 characters') +export const extensionSlugSchema = z.string().min(1).max(64).regex(/^[a-z][a-z0-9-]*$/) + +export function createExtensionId(slug: string): string { + return `p${crypto.randomUUID().replaceAll('-', '')}.${extensionSlugSchema.parse(slug)}` +} diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index d2523771..370536c7 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { workbenchSlashSchema } from '../workbench/workbenchCommand' import { workbenchConditionSchema } from '../workbench/workbenchContext' import { workbenchAnchorSchema, workbenchControls, workbenchControlSchema, workbenchMenuSchema, workbenchMountTargetSchema, workbenchPresentationSchema, workbenchSlots, workbenchSlotSchema } from '../workbench/workbenchUi' +import { extensionAuthorSchema } from './extensionIdentity' export const EXTENSION_API_VERSION = 3 export const EXTENSION_PROTOCOL = 'lexora-extension' @@ -70,6 +71,7 @@ export const extensionManifestSchema = z.object({ format: z.enum(['compiled', 'source']).default('compiled'), id: extensionIdSchema, name: z.string().min(1).max(100), + author: extensionAuthorSchema.optional(), description: z.string().max(500).default(''), icon: extensionPathSchema.refine(value => /\.(?:svg|png|jpe?g|webp)$/i.test(value), 'Expected an SVG, PNG, JPEG or WebP icon').optional(), categories: z.array(z.string().min(1).max(40)).max(8).default([]), diff --git a/apps/buddy/shared/workbench/workbenchCommand.ts b/apps/buddy/shared/workbench/workbenchCommand.ts index b4a8a520..469a7101 100644 --- a/apps/buddy/shared/workbench/workbenchCommand.ts +++ b/apps/buddy/shared/workbench/workbenchCommand.ts @@ -5,7 +5,8 @@ export function qualifyWorkbenchCommand(namespace: string, name: string): string return `${namespace}:${name}` } -export interface WorkbenchSlashCommand { id: string, name: string, title: string, description?: string } +export interface WorkbenchCommandOrigin { id: string, name: string, author?: string, version: string, source?: string } +export interface WorkbenchSlashCommand { id: string, name: string, title: string, description?: string, origin?: WorkbenchCommandOrigin } export function parseSlashInvocation(value: string): { name: string, arguments: string } | null { const match = /^\/(\S+)(?:\s([\s\S]*))?$/u.exec(value.trim()) return match ? { name: match[1]!, arguments: match[2]?.trimStart() ?? '' } : null diff --git a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts index c022fab8..81e32675 100644 --- a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts +++ b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts @@ -43,7 +43,7 @@ export const workbenchDecorations = { } as const satisfies Record export const workbenchRuntimeCapabilities = { - 'commands': { title: { 'zh-CN': '注册输入命令', 'en-US': 'Registered commands' }, scope: 'application', description: 'API 3 commands may declare slash:{name,description?}. Host exposes /plugin-name:name only (plugin-name is the part after the publisher in the plugin ID); top-level names are system-owned. Local names match [a-z][a-z0-9-]* (max 64). Titles and descriptions may be localized. Duplicate local names or invalid/missing handler registrations fail validation or activation. Another active plugin using the same plugin namespace prevents activation. Slash executes exact names with string arguments and origin pane; no model request. See commands.md.' }, + 'commands': { title: { 'zh-CN': '注册输入命令', 'en-US': 'Registered commands' }, scope: 'application', description: 'API 3 commands may declare slash:{name,description?}. Host exposes /plugin-name:name only (plugin-name is the stable suffix after the dot in the plugin ID); top-level names are system-owned. Local names match [a-z][a-z0-9-]* (max 64). Titles and descriptions may be localized. Duplicate local names or invalid/missing handler registrations fail validation or activation. Plugins may share command names. Choose a suggestion to bind its stable command ID; ambiguous plain text requires a choice. Slash executes exact names with string arguments and origin pane; no model request. See commands.md.' }, 'workbench.panes': { title: { 'zh-CN': '分屏布局订阅', 'en-US': 'Pane snapshots' }, scope: 'application', description: 'API 3 host workbench.panes and onPanesChange expose opaque id, active, visible, and rect relative to the workbench mount. Subscribe to layout and visibility changes; no task identity, history, or DOM access. See interactions.md.' }, 'workbench.interactions': { title: { 'zh-CN': '临时交互', 'en-US': 'Transient interactions' }, scope: 'application', description: 'API 3 interactions.start(title) returns id, AbortSignal, end(). Mount placements on workbench/workbench.pane declare interaction:regions or exclusive and require {interactionId,instanceId?}. Host exit and Escape revoke views without waiting for plugin cleanup. Regions mode only receives declared hit regions via view.interaction; other pixels pass through. Sessions never restore after restart. See interactions.md.' }, } as const satisfies Record diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index e9c86030..1c8b4491 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -71,7 +71,7 @@ useProvideWorkbenchCommands({ reportFailure: () => message.error(translateBuddy(stores.applicationSettings.language.value, 'desktop.command.inputFailed')), entries: computed(() => { void commandRevision.value - return [...workbench.controller.registry.commands.values()].flatMap(command => command.slash && (!command.enabled || command.enabled(workbench.controller.context)) ? [{ id: command.id, name: command.slash.name, title: command.label, description: command.slash.description }] : []) + return [...workbench.controller.registry.commands.values()].flatMap(command => command.slash && (!command.enabled || command.enabled(workbench.controller.context)) ? [{ id: command.id, name: command.slash.name, title: command.label, description: command.slash.description, origin: command.slash.origin }] : []) }), execute: async (id, argumentsText, instanceId) => { const controller = workbench.controller @@ -94,7 +94,7 @@ 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) -useProvideExtensionContext({ state: extensions, views: extensionViews, anchors, ui, workbench: pages.context, language: stores.applicationSettings.language, isDark: toRef(() => props.isDark), startCreation: prompt => workbench.startTaskWithSkill('plugin-creator', prompt), endInteraction: id => workbench.controller.interactions.end(id), focusView: (id) => { +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) } }) onScopeDispose(workbench.controller.subscribe(() => void nextTick(extensionViews.layout))) diff --git a/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts b/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts index 4b0cb20a..75075a51 100644 --- a/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts +++ b/apps/buddy/src/app/workbench/__tests__/useExtensionContributions.spec.ts @@ -272,5 +272,5 @@ it('exposes plugin short names as namespaces without publisher prefixes or globa const f = setup() f.installed.value = [{ ...f.status, revision: 'next', manifest: extensionManifestSchema.parse({ ...f.status.manifest, entry: 'host.js', apiVersion: 3, contributes: { ...f.status.manifest.contributes, commands: [{ id: 'tests.music.review', title: '检查播放列表', slash: { name: 'review', description: '检查当前播放列表' } }] } }) }] await settle() - expect([...f.controller.registry.commands.values()].filter(command => command.slash).map(command => command.slash)).toEqual([{ name: 'music:review', description: '检查当前播放列表' }]) + 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 } }]) }) diff --git a/apps/buddy/src/app/workbench/useExtensionContributions.ts b/apps/buddy/src/app/workbench/useExtensionContributions.ts index 3b818a5e..3d7b64ed 100644 --- a/apps/buddy/src/app/workbench/useExtensionContributions.ts +++ b/apps/buddy/src/app/workbench/useExtensionContributions.ts @@ -38,7 +38,7 @@ export function useExtensionContributions(options: { controller: WorkbenchContro scope.placement({ id: placement.id, viewType: placement.view, location: 'mount', target: placement.target, presentation: placement.presentation, interaction: placement.interaction, when: placement.when }) } for (const command of item.manifest.contributes.commands.filter(command => !command.hidden)) { - scope.command({ id: command.id, label: command.title, slash: command.slash ? { ...command.slash, name: qualifyWorkbenchCommand(extensionCommandNamespace(id), command.slash.name) } : undefined, enabled: () => matchesWorkbenchContext(command.when, controller.contextKeys.snapshot()), execute: async ({ view, pane, source, arguments: argumentsText }) => { + scope.command({ id: command.id, label: command.title, slash: command.slash ? { ...command.slash, name: qualifyWorkbenchCommand(extensionCommandNamespace(id), command.slash.name), origin: { id, name: item.manifest.name, author: item.manifest.author, version: item.manifest.version, source: item.source?.artifact } } : undefined, enabled: () => matchesWorkbenchContext(command.when, controller.contextKeys.snapshot()), execute: async ({ view, pane, source, arguments: argumentsText }) => { if (source === 'slash') return api.executeSlash(id, command.id, argumentsText ?? '', pane?.id) const parsed = view && ['file', 'file-preview'].includes(view.resource.scheme) ? spaceFileTargetSchema.safeParse(view.resource.data) : null diff --git a/apps/buddy/src/modules/extensions/extensionContext.ts b/apps/buddy/src/modules/extensions/extensionContext.ts index e7950958..8fa17d4a 100644 --- a/apps/buddy/src/modules/extensions/extensionContext.ts +++ b/apps/buddy/src/modules/extensions/extensionContext.ts @@ -16,6 +16,7 @@ export interface ExtensionContext { isDark: Readonly> endInteraction: (id: string) => void focusView: (id: string) => void + authoring: { author: Readonly>, save: (author: string) => Promise } startCreation: (prompt: string) => Promise } const [useProvideExtensionContext, injectExtensionContext] = createInjectionState((context: ExtensionContext) => context) diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionAuthoringSettings.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionAuthoringSettings.vue new file mode 100644 index 00000000..adaa9181 --- /dev/null +++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionAuthoringSettings.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionCard.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionCard.vue index af51b022..9e734a74 100644 --- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionCard.vue +++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionCard.vue @@ -1,8 +1,6 @@