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
27 changes: 17 additions & 10 deletions apps/buddy/electron/main/extensions/registerExtensionIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ThemeService } from '../../../platform/themes/ThemeService'
import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskActionResult } from '../../../shared/extensions/extensionActionApi'
import type { ExtensionAgentCatalog, ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent'
import type { ExtensionReview, ExtensionReviewResult, ExtensionWorkbenchEvent } from '../../../shared/extensions/extensionApi'
import type { ExtensionReview, ExtensionReviewResult, ExtensionWorkbenchEvent, ExtensionWorkbenchResult } from '../../../shared/extensions/extensionApi'
import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring'
import type { ExtensionConditionRuntime } from '../../../shared/extensions/extensionConditionContext'
import type { TaskFileTarget } from '../../../shared/files/taskFileApi'
Expand All @@ -20,6 +20,7 @@ import { seedBundledThemes } from '../../../platform/themes/bundledThemes'
import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic'
import { EXTENSION_IPC, extensionError, extensionManagementSchema } from '../../../shared/extensions/extensionApi'
import { ExtensionCompatibilityError } from '../../../shared/extensions/extensionCompatibility'
import { extensionReferenceCaptureSchema } from '../../../shared/extensions/extensionReferences'
import { assertTrustedSender } from '../ipc'
import { compileExtension } from './compileExtension'
import { createExtensionClipboard } from './ExtensionClipboard'
Expand Down Expand Up @@ -62,7 +63,7 @@ export function registerExtensionIpc(options: {
const protocol = new ExtensionProtocol(store)
const stopProtocol = protocol.install(session.defaultSession, 'view')
const hosts = new Set<SandboxedExtensionHost>()
const replies = new Map<string, (value: string | null) => void>()
const replies = new Map<string, { kind: ExtensionWorkbenchEvent['kind'], finish: (result: ExtensionWorkbenchResult) => void }>()
const bound = new Set<BrowserWindow>()
let resourcePickerOpen = false
const guardNavigation = (event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) => {
Expand Down Expand Up @@ -157,8 +158,8 @@ export function registerExtensionIpc(options: {
current.webContents.send(EXTENSION_IPC.workbench, { kind: 'cancel', requestId: event.requestId } satisfies ExtensionWorkbenchEvent)
finish(null)
}
const timer = event.kind === 'reference' ? undefined : setTimeout(cancel, 10000)
function finish(value: string | null) {
const timer = event.kind === 'reference' || event.kind === 'reference-commit' ? undefined : setTimeout(cancel, 10000)
function finish(value: ExtensionWorkbenchResult) {
if (settled)
return
settled = true
Expand All @@ -167,7 +168,7 @@ export function registerExtensionIpc(options: {
replies.delete(event.requestId)
resolve(value)
}
replies.set(event.requestId, finish)
replies.set(event.requestId, { kind: event.kind, finish })
signal.addEventListener('abort', cancel, { once: true })
current.webContents.send(EXTENSION_IPC.workbench, event)
}),
Expand Down Expand Up @@ -213,7 +214,7 @@ export function registerExtensionIpc(options: {
current.webContents.on('render-process-gone', resetHosts)
current.webContents.once('destroyed', () => {
bound.delete(current)
for (const reply of replies.values()) reply(null)
for (const reply of replies.values()) reply.finish(null)
resetHosts()
})
}
Expand Down Expand Up @@ -301,9 +302,15 @@ export function registerExtensionIpc(options: {
const current = window()
if (!current || current.isDestroyed() || event.sender !== current.webContents || event.senderFrame !== current.webContents.mainFrame)
return
const input = z.object({ requestId: z.string().uuid(), viewId: z.string().uuid().nullable() }).strict().safeParse(raw)
if (input.success)
replies.get(input.data.requestId)?.(input.data.viewId)
const input = z.object({ requestId: z.string().uuid(), result: z.unknown() }).strict().safeParse(raw)
if (!input.success)
return
const reply = replies.get(input.data.requestId)
if (!reply)
return
const result = (reply.kind === 'reference-capture' ? extensionReferenceCaptureSchema : z.string().uuid()).nullable().safeParse(input.data.result)
if (result.success)
reply.finish(result.data)
}
ipcMain.on(EXTENSION_IPC.workbenchReply, onWorkbenchReply)
ipcMain.handle(EXTENSION_IPC.hostRequest, async (event, method: unknown, params: unknown) => {
Expand All @@ -327,7 +334,7 @@ export function registerExtensionIpc(options: {
}
finally {
for (const subscription of subscriptions) subscription.dispose()
for (const reply of replies.values()) reply(null)
for (const reply of replies.values()) reply.finish(null)
for (const current of bound) {
if (current.isDestroyed())
continue
Expand Down
5 changes: 4 additions & 1 deletion apps/buddy/electron/main/extensions/runtime/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function request(method, params = null) {
if (pending.size >= 64)
return reject(new Error('EXTENSION_REQUEST_LIMIT'))
const id = crypto.randomUUID()
const longRunning = waitsForExtensionIo(method) || ['commands.execute', 'references.add'].includes(method)
const longRunning = waitsForExtensionIo(method) || ['commands.execute', 'references.add', 'references.commit'].includes(method)
const timer = longRunning
? undefined
: setTimeout(() => {
Expand Down Expand Up @@ -162,6 +162,9 @@ async function initialize() {
setPresentation: presentation => request('view.setPresentation', presentation),
references: Object.freeze({
add: input => request('references.add', input.kind === 'resource' ? { ...input, resource: { id: input.resource.id } } : input),
capture: input => request('references.capture', input.kind === 'resource' ? { ...input, resource: { id: input.resource.id } } : input),
commit: (id, choice) => request('references.commit', { id, choice }),
release: id => request('references.release', { id }),
onReveal: handler => references.onReveal(handler),
}),
resources: {
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/electron/preload/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@ export function createExtensionApi(): ExtensionApi {
onChanged: listener => subscribe(EXTENSION_IPC.changed, listener),
onReview: listener => subscribe(EXTENSION_IPC.review, listener),
onWorkbench: listener => subscribe(EXTENSION_IPC.workbench, listener),
replyWorkbench: (requestId, viewId) => ipcRenderer.send(EXTENSION_IPC.workbenchReply, { requestId, viewId }),
replyWorkbench: (requestId, result) => ipcRenderer.send(EXTENSION_IPC.workbenchReply, { requestId, result }),
} satisfies ExtensionApi)
}
36 changes: 25 additions & 11 deletions apps/buddy/platform/extensions/ExtensionService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../shared/extensions/extensionAgent'
import type { ExtensionAgentResourceRequest, ExtensionAgentResourceResponse } from '../../shared/extensions/extensionAgentResources'
import type { ExtensionFileOpeningInput, ExtensionFileOpeningSource, ExtensionMenuInvocation, ExtensionPreparedFileOpening, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi'
import type { ExtensionFileOpeningInput, ExtensionFileOpeningSource, ExtensionMenuInvocation, ExtensionPreparedFileOpening, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent, ExtensionWorkbenchResult } from '../../shared/extensions/extensionApi'
import type { ExtensionInspection } from '../../shared/extensions/extensionAuthoring'
import type { ExtensionClipboardData } from '../../shared/extensions/extensionClipboard'
import type { ExtensionConditionRuntime } from '../../shared/extensions/extensionConditionContext'
Expand Down Expand Up @@ -43,7 +43,7 @@ import { extensionDiagnostic, ExtensionRequestError } from '../../shared/extensi
import { extensionDocumentInfoSchema } from '../../shared/extensions/extensionDocumentInfo'
import { extensionFileHandlerBindings } from '../../shared/extensions/extensionFileOpening'
import { extensionCompatible, extensionManifestSchema, extensionPathSchema } from '../../shared/extensions/extensionManifest'
import { extensionReferenceInputSchema, extensionReferenceOpeningSchema, extensionReferenceTargetSchema } from '../../shared/extensions/extensionReferences'
import { extensionReferenceCaptureSchema, extensionReferenceCommitSchema, extensionReferenceInputSchema, extensionReferenceOpeningSchema, extensionReferenceTargetSchema } from '../../shared/extensions/extensionReferences'
import { extensionDirectoryScanSchema, extensionResourceCreateSchema, extensionResourceSelectionSchema } from '../../shared/extensions/extensionResources'
import { extensionNotificationSchema, extensionScheduleIdSchema, extensionScheduleInputSchema } from '../../shared/extensions/extensionSchedule'
import { extensionConfigurationAppliedSchema, resolveExtensionConfiguration, validateExtensionSetting } from '../../shared/extensions/extensionSettings'
Expand Down Expand Up @@ -79,7 +79,7 @@ export interface ExtensionServicePorts {
themes?: ThemeService
createHost: (pkg: ExtensionPackage, broker: (method: string, params: unknown) => Promise<JsonValue>, failed: (failure?: ExtensionHostFailure) => void) => ExtensionHost
createView: (pkg: ExtensionPackage, readSelected?: (id: string, request: Request, signal: AbortSignal) => Promise<Response>) => { token: string, url: string, dispose: () => void }
workbench: (event: ExtensionWorkbenchEvent, signal: AbortSignal) => Promise<string | null>
workbench: (event: ExtensionWorkbenchEvent, signal: AbortSignal) => Promise<ExtensionWorkbenchResult>
readText: (target: SpaceFileTarget, signal: AbortSignal) => Promise<string>
openExternal?: (url: string) => Promise<void>
readResource?: (target: SpaceFileTarget, signal: AbortSignal, request?: Request) => Promise<Response>
Expand Down Expand Up @@ -112,6 +112,7 @@ interface RunningExtension {
panesPending?: boolean
}
interface RunningView {
referenceCapture?: string
input: ExtensionViewInput
session: ExtensionViewSession
running: RunningExtension
Expand Down Expand Up @@ -1194,18 +1195,31 @@ export class ExtensionService {
throw new Error('EXTENSION_VIEW_EXPIRED')
}, view.abort.signal, view.input.resource?.id ?? null)
}
else if (method === 'references.add') {
else if (['references.add', 'references.capture', 'references.commit', 'references.release'].includes(method)) {
if (view.running.package.manifest.apiVersion < 4 || !view.running.package.manifest.permissions.references)
throw new Error('EXTENSION_REFERENCES_DENIED')
if (!['context', 'page'].includes(contribution.location) || (placement && placement.kind !== 'view'))
throw new Error('EXTENSION_METHOD_DENIED')
const input = extensionReferenceInputSchema.parse(params)
const material = await this.#referenceMaterial(view, input)
this.#assertCurrent(view.running)
if (view.abort.signal.aborted || this.#views.get(id) !== view)
throw new Error('EXTENSION_VIEW_EXPIRED')
const accepted = await this.#ports.workbench({ kind: 'reference', requestId: randomUUID(), viewId: id, generation, token, material }, AbortSignal.any([view.abort.signal, view.running.abort.signal]))
result = accepted === id
const binding = { requestId: randomUUID(), viewId: id, generation, token }
const signal = AbortSignal.any([view.abort.signal, view.running.abort.signal])
if (method === 'references.commit' || method === 'references.release') {
const event: ExtensionWorkbenchEvent = method === 'references.commit'
? { ...binding, kind: 'reference-commit', ...extensionReferenceCommitSchema.parse(params) }
: { ...binding, kind: 'reference-release', ...z.object({ id: z.string().uuid() }).strict().parse(params) }
result = await this.#ports.workbench(event, signal) === id
}
else {
if (method === 'references.capture')
view.referenceCapture = binding.requestId
const material = await this.#referenceMaterial(view, extensionReferenceInputSchema.parse(params))
if (method === 'references.capture' && view.referenceCapture !== binding.requestId)
return null
this.#assertCurrent(view.running)
if (view.abort.signal.aborted || this.#views.get(id) !== view)
throw new Error('EXTENSION_VIEW_EXPIRED')
const accepted = await this.#ports.workbench({ ...binding, kind: method === 'references.capture' ? 'reference-capture' : 'reference', material }, signal)
result = method === 'references.capture' ? (accepted === null ? null : extensionReferenceCaptureSchema.parse(accepted)) : accepted === id
}
}
else if (method === 'view.setActive') {
if (placement?.kind !== 'slot' && placement?.kind !== 'control')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,13 @@ export interface ReferenceLocator {
readonly version: number
readonly data: ReadonlyJson
}
export type ReferenceChoice = { kind: 'view', id: string } | { kind: 'new-task' | 'clipboard' }
export interface ReferenceCapture {
readonly id: string
readonly targets: readonly { readonly id: string, readonly label: string }[]
readonly defaultId: string | null
readonly clipboard: boolean
}
export interface ReferenceReveal {
readonly referenceId: string
readonly text: string
Expand Down Expand Up @@ -332,6 +339,9 @@ export interface ViewContext {
openExternal: (url: string) => Promise<void>
readonly references: {
add: (input: ReferenceInput) => Promise<boolean>
capture: (input: ReferenceInput) => Promise<ReferenceCapture | null>
commit: (id: string, choice: ReferenceChoice) => Promise<boolean>
release: (id: string) => Promise<boolean>
onReveal: (handler: (input: ReferenceReveal, signal: AbortSignal) => boolean | Promise<boolean>) => Disposable
}
readonly resources: LocalResourceApi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@

## 引用到对话

API 4 中 `location: "context"` 的交互资源视图可声明 `permissions.references: true`,通过 `context.references.add(input)` 选择任务草稿。该视图作为独立导航入口显示或没有可用任务时,内容引用改为复制到系统剪贴板;粘贴回当前 Lexora 窗口可恢复引用卡片与定位信息,重启或换窗口后仅保留文字。`location: "page"` 的视图、装饰、插槽与替换控件不支持此能力。成功加入草稿或复制引用返回 `true`,取消返回 `false`;授权或资源失效会抛错。等待用户选择没有固定的十秒期限;关闭视图或停用插件会取消请求。此操作不会发送消息或调用模型。
API 4 的 `context` 和 `page` 视图可声明 `permissions.references: true`,通过 `context.references.add(input)` 选择任务草稿、新建任务或复制引用。装饰、插槽与替换控件不支持此能力。成功加入草稿或复制引用返回 `true`,取消返回 `false`;授权或资源失效会抛错。等待用户选择没有固定的十秒期限;关闭视图或停用插件会取消请求。此操作不会保存源文档、发送消息或调用模型。

自有内容菜单使用 `references.capture(input)` 冻结材料和当时可接收的任务草稿,返回 `{ id, targets: [{ id, label }], defaultId, clipboard }`;视图不可用时返回 `null`。唯一目标的 `defaultId` 可作为主动作,多个目标由插件在子菜单呈现,同时提供新任务及允许时的复制入口。点击后调用 `references.commit(id, { kind: 'view', id: targetId })`、`{ kind: 'new-task' }` 或 `{ kind: 'clipboard' }`。提交会等待草稿持久化,不设固定十秒期限;成功返回 `true`,失效返回 `false`,不会悄悄选择后来激活的任务。菜单关闭时调用 `references.release(id)`。每个视图只保留最近一次捕获,隐藏、关闭、替换会话或已消费后失效;插件不能持久化或跨视图复用捕获 ID。捕获不保存源文档,也不修改草稿。该交互需要 Lexora 0.17.0 或更新版本。

```ts
await context.references.add({
Expand Down Expand Up @@ -95,7 +97,6 @@ context.references.onReveal(async ({ locator, text }, signal) => {

这两项增量 API 应先检查 `typeof context.setDocumentInfo === 'function'` 和 `context.clipboard`,缺失时提示升级宿主;清单兼容范围使用实际支持此能力的宿主版本。


## 文件关联与独立入口

使用下列新增声明时,`apiVersion` 保持 `4`,`engines.lexora` 必须以首次提供这些能力的应用版本为下限;不能仅凭旧宿主也支持 API 4 就声明兼容。应用版本和 API 版本分别约束能力可用性与协议兼容性。
Expand Down Expand Up @@ -357,5 +358,4 @@ API 3 的 `contributes.iconThemes` 可贡献独立的文件与目录图标主题

复杂效果仍可创建元素、设置样式,或引入包内 CSS:`link.href = new URL('./style.css', import.meta.url).href`。效果应柔和短暂,不遮挡正文或确认操作。CSS 只作用于插件自己的隔离页面。


临时覆盖层、分屏布局订阅与局部点击区域见 [交互会话](interactions.md)。
Loading