LFRuntime
全局变量:LFRuntime · 启用方式:自动注入,无需 @require · 别名:UserScriptContext
始终可用的应用运行时上下文:元信息、热重载生命周期、资源读取、样式注入、MAIN world 执行、页面请求拦截,以及 Agent 侧边栏控制。
属性
LFRuntime.bundleId: string当前应用(bundle)的唯一 ID
LFRuntime.meta: BundleMeta入口元信息 { id, name, description, entryFile, matchPatterns }
LFRuntime.url: string脚本开始执行时的 location.href
LFRuntime.version: string宿主扩展 PluginX 的版本号(manifest.json,如 "3.0.6"),不是应用自身的版本;无法解析时为空串
LFRuntime.liveDebug: boolean是否处于调试注入(run / liveDebug 时为 true,已保存应用为 false)
LFRuntime.executionEnvironment: 'page' | 'background' | 'editor-sandbox'当前执行环境:page、background 或 editor-sandbox;由编译产物确定。
LFRuntime.isBackground: boolean是否运行在后台沙箱,等价于 executionEnvironment === "background"。
LFRuntime.signal: AbortSignal | null终端 run 进程的中止信号(Ctrl+C / kill / agent 停止时 abort);liveDebug 与已启用/已安装应用中为 null,热重载不会触发它
LFRuntime.network: { onResponse, interceptRequest, interceptResponse }页面 fetch/XHR 拦截入口(observe / mock / replace),需要头部声明 @intercept
LFRuntime.cli: { register, unregister }注册可被 plugincmd 发现的终端命令
LFRuntime.sidebar: { getState, getAgentState, isSupported, open, close, input, setModel }查询与控制PluginX Agent 侧边栏:开关、向输入框送文字、切换对话模型
LFRuntime.input: { click, hover, focus, fill, clear, press, type, text, key, check, uncheck, select, scrollIntoView, readText, move, down, up, drag, scroll }可信输入(CDP,事件 isTrusted === true),只作用于脚本所在的标签页;text 是 type 的别名。首次使用会附加调试器,浏览器会显示调试横幅;纯页面交互用 LFDOM 更轻
方法
LFRuntime.sleep(ms): Promise<void>延时,等价于可中止的 setTimeout
LFRuntime.throwIfAborted(): void若终端 run 进程已被中止则抛出中止原因,用于长循环里及时退出;signal 为 null 时不做任何事
LFRuntime.registerCleanup(callback): typeof callback注册清理回调,liveDebug 热重载重新注入前自动执行;返回传入的 callback 本身(不是注销函数,登记后无法取消)。只在 liveDebug 与终端 run 中登记,已启用/已安装应用中什么也不做
LFRuntime.trackNode(node): Node登记 DOM 节点,重新注入时自动移除
LFRuntime.listen(target, type, handler, options?): () => voidaddEventListener 的热重载安全版本:重新注入时自动移除监听;返回手动取消函数
LFRuntime.interval(callback, ms, ...args): numbersetInterval 的热重载安全版本:重新注入时自动 clearInterval
LFRuntime.timeout(callback, ms, ...args): numbersetTimeout 的热重载安全版本:重新注入时自动 clearTimeout
LFRuntime.mountHot(key, setup): T | undefined热重载友好的挂载:同 key 重挂载前自动执行上一次的清理
LFRuntime.clearHot(key): void手动清理指定 key 的热挂载
LFRuntime.getResource(name): string读取 @resource 声明的资源:文本资源返回原文,二进制资源返回 data: URL,未声明的别名返回空串(可用 !== "" 判断是否存在)
LFRuntime.appendStyle(cssText, styleKey?): HTMLStyleElement注入 CSS;同一 styleKey(缺省为 "inline")重复调用会替换内容而非叠加,需要多段样式时请传不同的 styleKey。CSS 类型的 @resource 会在启动时以别名为 key 自动注入
LFRuntime.runInMainWorld(sourceOrFunction, options?): Promise<T>把函数或源码放到页面 MAIN world 执行并取回结果
LFRuntime.execTerminalCommand(command, options?): Promise<Result>在编辑器终端执行命令(仅终端临时脚本:run / run -c / run --tab / run --target background)
LFRuntime.readFile(path, options?): Promise<{ ok, content, ... }>读取工作区文件,文本在返回值的 .content 字段(仅终端临时脚本,同上)
LFRuntime.writeFile(path, content, options?): Promise<Result>写入工作区文件,支持字符串与二进制(仅终端临时脚本,同上)
LFRuntime.sidebar.getState(): Promise<{ open, supported }>读取侧边栏的打开状态与当前浏览器是否支持侧边栏
LFRuntime.sidebar.getAgentState(): Promise<{ runStatus, isRunning, ready, submitAllowed, ... }>读取 Agent 执行状态、运行准备状态与提交授权探测结果;面板未打开时也可调用。
LFRuntime.sidebar.isSupported(): Promise<boolean>仅返回当前浏览器是否支持侧边栏
LFRuntime.sidebar.open(): Promise<{ open: true, windowId, alreadyOpenInAnotherWindow? }>打开 Agent 侧边栏(必须在用户点击回调中作为第一个异步调用)
LFRuntime.sidebar.close(): Promise<{ open: false }>关闭 Agent 侧边栏
LFRuntime.sidebar.input(text, options?): Promise<{ accepted: true, insertedLength, submitted, mode, ... }>向已打开的 Agent 输入框写入文字并聚焦;默认覆盖草稿、不提交,追加与提交行为由 options 指定。
LFRuntime.sidebar.setModel(modelId): Promise<{ modelId, addedToQuickList }>切换已打开侧边栏的 Agent 模型;快捷列表中没有时自动添加,不存在时抛异常
LFRuntime.print(...args): void终端 run 脚本中输出到终端 stdout(可重定向);liveDebug 与已启用/已安装应用中写入页面 console,liveDebug 下可用终端 diag 查看;参数全部为 Error 实例时走 stderr。务必写全 LFRuntime.print(...)——裸写 print() 调用的是 window.print(),会弹出浏览器打印对话框
可用性
同一个 API 在不同执行方式下行为不同。「已启用/已安装」指保存编译后注入网页的应用;「后台应用」指 // @match background 应用(含 both 应用的后台分支)的常驻实例。
| 能力 | 已启用/已安装(page) | liveDebug | run --tab | 不带 --tab 的 run | run --target background | 后台应用 |
|---|---|---|---|---|---|---|
print 输出位置 | 页面 console | 页面 console(diag 可查) | 终端 | 终端 | 终端 | 后台沙箱 console |
signal / throwIfAborted | null / 无操作 | null / 无操作 | 生效 | 生效 | 生效 | null / 无操作 |
readFile / writeFile / execTerminalCommand 直接调用 | 不可用¹ | 不可用¹ | 可用 | 可用 | 可用 | 不可用¹ |
network | 需声明 @intercept | 可用 | 可用 | 不生效 | 不生效 | 不生效 |
runInMainWorld | 可用 | 可用 | 可用² | 在编辑器沙箱内直接执行(没有目标网页) | reject MAIN_WORLD_NO_TAB | reject MAIN_WORLD_NO_TAB |
sidebar | 可用 | 可用 | 可用 | 除 open() 外可用 | 除 open() 外可用 | 除 open() 外可用 |
input | 本地开发版可用;市场上架版需 LFRuntime.input 能力授权 | 可用 | 可用 | reject RUNTIME_INPUT_NO_TAB | reject RUNTIME_INPUT_NO_TAB | reject RUNTIME_INPUT_NO_TAB |
cli | 可用 | 可用 | 可用 | 可用 | 可用 | 可用 |
¹ 应用源码里直接调用会在编译期报错;绕过编译检查时运行时 reject WORKSPACE_LEASE_REQUIRED。应用代码请用 LFRuntime.cli.register({ workspace: ['read' / 'write' / 'exec'] }) 注册命令,在 handler 内通过 ctx.workspace.readFile / writeFile / exec 访问。
² run --tab 默认运行在 MAIN world,回调直接在当前 world 执行,options.timeoutMs 不生效;--world user-script 时经后台桥接到 MAIN world。
本地能力边界
- 本机命令执行(
localexec)、本机文件访问(local fs)与 CLI 注册表请求(plugincmd的列表与调用)只接受 PluginX 编辑器页面发起。插件应用——无论注入网页、运行在后台,还是处于 USER_SCRIPT world——直接发送这些请求都会以EACCES_PERMISSION被拒绝。插件代码访问工作区请用LFRuntime.cli.register({ workspace: [...] })注册命令,在 handler 内通过ctx.workspace.*访问。 - 权限弹窗的决定只接受扩展自身的权限弹窗页发出,请求 id 不可预测,应用无法替用户作答。
LFHttp.client.fs(客户端文件系统通道)按浏览器认证的应用身份归属:白/黑名单以认证身份判定,而不是调用方自报的 bundleId,应用无法冒用其它应用的 bundleId 访问它的文件。
参数详解
mountHot(key, setup)
key 为挂载槽的稳定标识(内部自动加 bundleId 前缀,不同应用不会冲突)。setup 必须是同步函数,收到一个 hot 上下文对象:
| 字段 | 类型 | 说明 |
|---|---|---|
hot.key | string | 归一化后的 key |
hot.liveDebug | boolean | 同 LFRuntime.liveDebug |
hot.previousValue | T / undefined | 上一代 setup 返回的值(首次运行为 undefined),用于复用已挂载的节点 |
hot.onDispose(cleanup) | (fn) => fn | 注册下次卸载时执行的清理函数 |
hot.setValue(value) | (v) => v | 手动设置槽值(当你想同时返回清理函数时使用) |
setup 的返回值按类型解释:返回函数 → 视为清理函数(槽值保持上一代);返回 { value?, cleanup? } 对象 → 拆解为槽值与清理;返回其他非 undefined 值 → 作为新槽值;返回 undefined → 保持上一代值。
每个挂载槽只保留一个清理函数:多次调用 onDispose 以最后一次为准,setup 返回函数(或带 cleanup 的对象)时也会覆盖 onDispose 登记的函数。需要多个清理动作时请合并到一个函数里。
热重载幂等语义(保存源码触发的软热更新时发生了什么)
软热更新(保存源码自动触发,或重复执行 liveDebug on)会在当前 document 内重新执行整个 bundle。执行新代码之前,框架会依次调用上一轮通过 registerCleanup / trackNode / listen / interval / timeout / mountHot 登记的清理;但裸调用不受管理——直接 addEventListener / setInterval 挂上的副作用、写到 window 上的全局属性都会跨软热更新存活并与新代码叠加(重复监听、旧闭包继续运行)。终端命令 liveDebug reload 则不同:它会整页刷新页面后重新注入,上一代所有残留(注册过与否)一并销毁。
| 写法 | 热重载行为 | 建议 |
|---|---|---|
LFRuntime.listen / interval / timeout / trackNode / mountHot | reload 时自动清理 | 顶层副作用一律用这些助手 |
裸 addEventListener / setInterval / setTimeout | 旧监听器/定时器继续存活,与新代码叠加 | 仅在 mountHot 的 cleanup 内自行管理时使用 |
window.__myFlag 之类的守卫标志 | 跨 reload 保留 → 新代码误判「已初始化」而跳过挂载 | 反模式,不要用;幂等交给 mountHot 的 key 语义 |
| 长循环 / 轮询任务 | 旧任务不会被自动打断 | 用 let alive = true; LFRuntime.registerCleanup(() => { alive = false; }); 并在循环条件里检查 alive;throwIfAborted() / signal 只对终端 run 进程生效,热重载不会触发 |
不要用守卫标志实现幂等
if (window.__inited) return; 在热重载下必然失效:旧标志跨 reload 保留,新代码会直接跳过挂载,表现为「改了代码但页面没变化」。正确做法是把挂载放进 mountHot(key, setup)——同 key 重挂载前框架自动执行上一次的清理,天然幂等。
runInMainWorld(sourceOrFunction, options?)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
sourceOrFunction | string / Function | — | 自包含的函数或括号包裹的表达式;不能闭包引用外部局部变量,参数通过 options.args 或 options.bindings 传入 |
options.args | unknown[] | — | 传给 MAIN world 函数的参数,须可结构化克隆;已挂载到当前 document 的 Element 会自动桥接 |
options.bindings | Record<string, unknown> | — | 显式注入回调词法作用域的局部值(键须是合法标识符,值须可结构化克隆) |
options.dependencies | Record<string, Function / string> | — | 在回调之前声明的自包含 helper 函数/表达式;helper 自身同样不能捕获外部闭包 |
options.preludes | string[] | — | 在回调私有作用域内、dependencies 之前原样执行的源码(如以文本形式取得的 UMD/IIFE 库构建),用于把库带进 MAIN world |
options.timeoutMs | number | 10000 | 超时毫秒数;缺省或传 0 为 10000,超出范围会被钳制到 [1000, 60000] |
回调内只有 LFRuntime.print 与 LFRuntime.sleep 可用,访问其它 LF* 成员会抛 MAIN_WORLD_RUNTIME_UNAVAILABLE;请让回调只返回普通数据,其余 LF* 调用放在回调外。返回值跨 world 上限 16 MiB,超出 reject MAIN_WORLD_RESULT_TOO_LARGE。各执行方式下是否可用见「可用性」。
execTerminalCommand(command, options?)
本节与下一节的三个工作区方法只能在终端临时脚本(run / run -c / run --tab / run --target background)中直接调用。应用源码(liveDebug 与已启用/已安装/后台应用)中直接调用会在编译期报错,运行时 reject WORKSPACE_LEASE_REQUIRED;应用代码请用 LFRuntime.cli.register({ workspace: ['read' / 'write' / 'exec'] }) 注册命令,在 handler 内通过 ctx.workspace.readFile / writeFile / exec 访问(语义与这里相同)。
在编辑器内置的 /workspace 终端(非宿主系统 shell)中执行命令。options 字段:timeoutMs(默认 30000,范围 [1000, 60000])、echoCommand(是否回显命令)、recordHistory(是否记入历史)、writePrompt(结束后是否输出新提示行)、focusTerminal(是否聚焦终端面板)。返回值结构:
| 字段 | 类型 | 说明 |
|---|---|---|
ok | boolean | 便捷标志,默认等价于 exitCode === 0 |
exitCode | number | 命令退出码(命令不存在为 127) |
stdout / stderr | string | 捕获的标准输出 / 标准错误(无输出时为空串) |
message | string | 桥接层附加的说明信息(通常为空) |
clearRequested | boolean | 命令是否请求清屏(如 clear) |
readFile(path, options?) / writeFile(path, content, options?)
两者都是异步方法,必须 await;返回的是信封对象而非文件内容本身——文件文本在 .content 字段(常见错误:JSON.parse(await LFRuntime.readFile(p)) 是错的,应先解构 const { content } = await LFRuntime.readFile(p))。
| 参数/字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
path | string | — | 工作区相对路径(如 src/foo.ts)或 /workspace/... 前缀;仅 /workspace 内可写 |
options.timeoutMs | number | 30000 | 范围 [1000, 60000] |
options.encoding(读) | 'utf8' / 'base64' / 'binary' / null | 'utf8' | utf8 返回文本;binary(或 null,以及别名 'buffer' / 'uint8array')返回 Uint8Array;base64 返回 base64 字符串。无法识别的值(如 'utf-8')按 utf8 处理。读二进制文件(PNG/字体等)务必用 binary |
options.encoding(写) | 'utf8' / 'base64' | 'utf8' | 仅当 content 是字符串时生效;二进制 buffer 始终按原始字节写入 |
options.mkdirP(写) | boolean | true | 自动创建缺失的父目录(类似 mkdir -p) |
content(写) | string / Uint8Array / ArrayBuffer / TypedArray / DataView | — | 二进制 buffer 直接传即可(如 await response.arrayBuffer());不要把二进制响应经 response.text() 转字符串再写——有损且不可恢复。null/undefined 写入空文件;其它非字符串、非二进制的值会被 String() 转换(对象会写成 [object Object]),写 JSON 请先 JSON.stringify |
readFile 在 utf8 模式返回 { ok, path, content, message },binary/base64 模式额外包含 encoding 与 bytesRead;writeFile 返回 { ok, path, bytesWritten, message }。文件不存在、路径是目录或父路径不是目录时 readFile 不抛错,而是 resolve { ok: false },content 为空串(binary 模式为空 Uint8Array)——请用 ok === false 判断;传输、超时等其它错误仍会 reject。
network.onResponse / interceptRequest / interceptResponse(urlPattern, callback, options?)
| 参数 | 类型 | 说明 |
|---|---|---|
urlPattern | string / RegExp | 字符串按子串匹配请求 URL;正则按 test 匹配 |
callback | Function | onResponse 收到 { request, response, status, source }(request/response 含 url/method/headers/body);interceptRequest 收到 request,返回 { body, init? } 即短路模拟响应(真实请求不发出),返回 undefined 放行;interceptResponse 收到 { request, response },返回 { body, init? } 替换页面看到的响应 |
options.once | boolean | 只生效一次后自动取消订阅:onResponse 首次命中即消耗;interceptRequest / interceptResponse 仅在回调实际返回了数据时消耗(返回 undefined 放行不算) |
options.timeout / timeoutHandler | number / () => void | 超时未命中则自动取消订阅并调用 timeoutHandler |
返回值中的 body 可以是字符串、ArrayBuffer、TypedArray 或 DataView(二进制体原样送达页面,不要先转成 base64 字符串);普通对象/数组会被 JSON.stringify 后作为响应体;Blob / FormData / URLSearchParams 不支持——这次结果会被拒绝并在页面 console 报错,请求按原样放行。
三个方法都返回取消订阅函数,liveDebug 热重载时订阅会自动释放;后台应用、run --target background 与不带 --tab 的 run 没有页面,订阅永远不会命中。init 默认 { status: 200, statusText: 'OK', headers: { 'Content-Type': 'application/json' } }。注意:只能「模拟或放行」,不能修改请求后继续发送;已安装应用必须在头部声明 // @intercept <url-pattern>。拦截范围仅限页面自身 JS 发起的 fetch/XHR:应用代码运行在隔离世界,自己调用的 fetch 与 LFHttp 请求不会经过拦截,也不会触发 onResponse。
cli.register(def)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
def.name | string | 是 | 命令名(plugincmd call <name> 使用),非空、无空白、≤64 字符;不合规时 register() 同步抛错 |
def.brief | string | 否 | 一行简介(≤200 字,超出部分静默截断),显示在 plugincmd list。IDE 会在每条用户消息里自动把可调用命令的 name: brief 注入 Agent 上下文(网页对话模式:当前 tab + 后台应用的注册;IDE 编辑器模式:后台应用的注册),所以请像写 skill 描述一样写清「做什么、什么时候用」 |
def.usage | string / string[] | 否 | 详细用法说明,显示在 plugincmd help <name>(上限 5 万字,超出部分静默截断;数组按换行连接)。按需加载,可以写完整的操作手册:参数约定、前置条件、执行步骤、注意事项——相当于 skill 的正文 |
def.workspace | ('read' / 'write' / 'exec' / 'agent')[] | 否 | 声明命令需要的工作区 scope:read/write/exec 分别解锁 ctx.workspace.readFile/writeFile/exec;agent 解锁 LFAgent 内置库(见「LFAgent」)。未知的 scope 在注册时即抛错。租约仅在本次 plugincmd call 期间有效,调用结束即吊销 |
def.handler | (argv, ctx) => string / { exitCode?, output?, errorOutput? } / void | 是 | 见下表 |
handler 的参数与返回值:
| 项 | 说明 |
|---|---|
argv | 命令名之后的参数数组。-- 可省略:从第一个非 plugincmd call 选项的参数起即为 argv;参数本身以 --reg / --tab / --bundle / --source / --timeout 开头时,需要先写 -- |
ctx.signal | 调用方中断(Ctrl+C / agent 中止 / IDE 关闭 / 超时)时触发,长任务应检查 ctx.signal.aborted |
ctx.callId | 本次调用的唯一 ID |
ctx.timeoutMs | 本次调用生效的超时(毫秒),0 表示不限时(调用方未传 --timeout,默认即不限时);必须不限时才能安全执行的任务可据此在入口主动拒绝限时调用 |
ctx.workspace | 工作区门面 { scopes, readFile, writeFile, exec },仅当命令声明了 def.workspace 且调用方带终端会话时存在;handler 结束后再调用会 reject WORKSPACE_LEASE_EXPIRED,调用未声明的 scope 会 reject WORKSPACE_SCOPE_DENIED |
| 返回值 | 字符串视为 stdout;返回 undefined 视为空输出;对象形式可自定义 exitCode。output / errorOutput 单次上限 100 万字符,超出截断 |
退出码:
| 退出码 | 含义 |
|---|---|
0 | 正常返回(字符串、undefined 或未指定 exitCode 的对象) |
1 | handler 抛错,错误消息写入 stderr |
126 | 市场上架版应用声明了 def.workspace 但不具备 LFRuntime.workspace 授权,调用以 WORKSPACE_UNAUTHORIZED 被拒,handler 不会执行 |
127 | 命令在提供方上下文中已不存在(例如已被注销) |
130 | 调用方中断 |
register() 返回注销函数;cli.unregister(name) 按名字注销当前上下文注册的命令。注册的生命周期与提供方上下文一致:run 脚本退出即注销,需要持续服务时脚本必须保持存活(如后台任务);编译应用的 page 实例随页面/标签页关闭注销;background 实例常驻(不依赖任何标签页,浏览器重启后随实例恢复自动重新注册),应用被禁用或卸载时注销。both 应用的两个分支各自注册一份同名命令,plugincmd 默认优先调用 background 分支。
sidebar.getState / getAgentState / isSupported / open / close / input / setModel
侧边栏对所有安装用户开放,这些方法不做角色区分(真正的鉴权由服务端接口把关)。整组方法需要能与后台通信的执行环境:已启用/已安装应用(含后台应用)、liveDebug、run --tab(编辑器会自动注入 ISOLATED 中继桥)、不带 --tab 的 run 与 run --target background 均可;没有可用通信通道时,方法返回的 Promise 会 reject(不会同步抛错)。后台应用、run --target background 与不带 --tab 的 run 没有调用方窗口,其中 open() 会被拒绝,其余方法正常。
| 方法 | 前置条件 | 返回值与行为 |
|---|---|---|
getState() | — | 返回 { open, supported }:open 以侧边栏是否有活动连接为准(面板是单实例,不按调用方窗口判断),supported 表示当前浏览器是否具备 Side Panel API |
getAgentState() | —(面板未打开不抛错) | 返回 Agent 执行态 { open, supported, runStatus, isRunning, stopRequested, ready, submitAllowed, sessionId, modelId }。runStatus:closed 面板未打开 / idle 可接受运行 / running 正在跑 / aborting 已请求停止但尚未收尾;ready 预判提交能否通过前置校验(已打开项目且已配置模型);submitAllowed 是不弹窗的授权探测 |
isSupported() | — | 返回 boolean,等价于 getState().supported,即浏览器能力探测 |
open() | 必须能确定调用方窗口(即脚本运行在网页里);必须在直接用户手势中作为第一个异步调用 | 打开调用方所在窗口的 Agent 侧边栏并等待面板建连,返回 { open: true, windowId };若面板已开在另一个窗口,则不重复打开,返回 { open: true, windowId: <该窗口>, alreadyOpenInAnotherWindow: true } |
close() | — | 关闭侧边栏,返回 { open: false } |
input(text, options?) | 侧边栏必须已打开;text 去除首尾空白后不可为空。submit: true 额外要求已获执行授权、侧边栏已打开项目且已配置模型 | 把文字写进当前 Agent 输入框并聚焦。默认覆盖原有草稿(mode: "replace"),追加请传 { mode: "append" }。默认不提交,由用户按发送;传 { submit: true } 则直接提交、让 agent 开跑。返回 { accepted: true, insertedLength, submitted, draftAppended, sessionId, mode } —— 传了 submit 也要检查 submitted |
setModel(modelId) | 侧边栏必须已打开;modelId 非空 | 按模型 ID、canonical slug 或别名切换当前 Agent 模型;不在快捷列表时先自动添加;不存在或不可用于 Agent 聊天时抛异常;返回 { modelId, addedToQuickList } |
open() 的调用时机
open() 底层是 chrome.sidePanel.open(),必须在瞬态用户激活失效前同步发出:在按钮 click 回调里把它作为第一个异步调用,前面不能有任何 await(把 isSupported() 之类的探测放到页面初始化阶段先做完)。它还需要从消息中解析出调用方窗口,因此只能在网页中调用——编辑器沙箱(不带 --tab 的 run)拿不到窗口 id,会直接拒绝。面板已开在另一个窗口时不会重复打开(两个面板等于两个 agent 运行时,为只留一个就得杀掉那边正在跑的对话),请读 alreadyOpenInAnotherWindow 并用返回的 windowId 提示用户切换过去。
input 的写入模式
input(text) 默认覆盖输入框里已有的草稿(等价于 { mode: "replace" }):应用递进来的通常是一条完整的话,拼在用户手打的半句后面很少是想要的结果——尤其配合 submit: true 时,那半句会被一起发出去。覆盖会连同用户已暂存的代码选区、文件引用一起清掉,因为选区 token 就嵌在草稿文本里,只清文字会留下孤儿引用。确实要做增量拼接(比如分几次把一段长内容送进去)时显式传 { mode: "append" },它会追加到草稿末尾并在需要时补一个换行。返回值里的 mode 会回显本次实际生效的模式。
| 模式 | 行为 |
|---|---|
"replace"(默认) | 先清空草稿(含代码选区/附件)再写入本次文字 |
"append" | 追加到现有草稿末尾,必要时补换行;保留已有选区/附件 |
input 的 submit 选项与授权
input(text) 只是把文字放进输入框,用户还要自己按发送——人始终在回路里,所以不需要任何授权。input(text, { submit: true }) 则直接开跑一轮 agent 对话,那一轮里 agent 可以读写工作区、跑终端命令、调模型花额度:这是把「应用建议一件事」升级成「应用替用户下令做一件事」,因此和本机命令执行同一量级地受一道闸约束。
| 选项 | 类型 | 默认 | 说明 |
|---|---|---|---|
mode | "replace" / "append" | "replace" | 文字怎么落进草稿:replace 先清空(含代码选区/附件)再写入;append 追加到末尾 |
submit | boolean | false | true 时提交并让 agent 立即执行;缺省只填输入框 |
onBusy | "reject" / "append_draft" | "reject" | 仅 submit 时有意义。agent 正在跑时:reject 抛错且不写入草稿(避免悄悄堆积);append_draft 静默降级为只追加草稿。没有排队机制 |
授权分两层,顺序固定:① Agent 设置 → Agent → 「允许应用向侧边栏 agent 发起执行命令」,默认开启,开着就直接放行;② 用户把它关掉后,改为按应用弹窗询问一次,同意即长期记住(与本机命令执行同一范式),拒绝则本次失败且不留授权记录、下次仍会问。也就是说关掉开关不是一刀切禁用,而是「改为逐应用询问」。
先探测,再决定要不要提交
getAgentState() 返回的 submitAllowed 是只读探测,不会弹窗。想避免打断用户的应用可以据此降级:submitAllowed 为 false 时就只填草稿并提示用户自己按发送。另外 agent 没有「运行结束」事件,提交后需要轮询 getAgentState().isRunning。终端 run 脚本的 bundleId 带 ::terminal-run::<pid> 后缀,授权会归到宿主应用名下,不会每跑一次都重新问。
侧边栏示例
// @require dom
// @require model
// 浏览器能力探测放在页面初始化阶段,不要等到 click 内再 await
const sidebarSupported = await LFRuntime.sidebar.isSupported();
function addButton(label, onClick) {
const button = LFRuntime.trackNode(document.createElement("button"));
button.textContent = label;
document.body.appendChild(button);
LFRuntime.listen(button, "click", onClick);
}
addButton("让 Agent 分析本页", async () => {
if (!sidebarSupported) return;
// open 必须是 click 回调中的第一个异步调用
const result = await LFRuntime.sidebar.open();
if (result.alreadyOpenInAnotherWindow) {
// 已开在别的窗口:不会重复打开,提示用户切换过去
LFDOM.toast("侧边栏已在其它窗口打开,窗口 ID " + result.windowId);
return;
}
// input / setModel 要求侧边栏已打开,所以放在 open() 之后
await LFRuntime.sidebar.input("请分析当前页面的商品信息");
// 默认覆盖草稿、只填入文字,由用户自己按发送
// 分几次拼一段长内容时才用追加
await LFRuntime.sidebar.input("补充:只看近 30 天的订单", { mode: "append" });
});
addButton("让 Agent 直接导出 CSV", async () => {
// 不调用 open(),所以可以先 await 其它方法
const { open } = await LFRuntime.sidebar.getState();
if (!open) {
LFDOM.toast("请先打开 Agent 侧边栏");
return;
}
// 从当前目录选择对话模型
const { models } = await LFModel.listModels();
const chatModel = models.find((model) => model.kind === "chat");
if (!chatModel) {
LFDOM.toast("当前列表无可用对话模型");
return;
}
// 若模型不在快捷列表会自动添加;模型不存在时直接抛出异常
await LFRuntime.sidebar.setModel(chatModel.id);
// 想让 agent 直接开跑:先看执行状态,避免撞上正在运行的一轮
const agentState = await LFRuntime.sidebar.getAgentState();
if (agentState.runStatus === "idle" && agentState.ready) {
// submitAllowed=false 说明这一次会弹授权窗,可据此降级为只填草稿
const sent = await LFRuntime.sidebar.input("统计本页所有商品并导出 CSV", {
submit: agentState.submitAllowed,
});
if (sent.submitted) {
// 没有"运行结束"事件,需要轮询
while ((await LFRuntime.sidebar.getAgentState()).isRunning) {
await LFRuntime.sleep(1000);
}
LFDOM.toast("Agent 已完成");
}
}
});示例
// LFRuntime 无需 @require,自动注入到每个应用
LFRuntime.print(LFRuntime.bundleId, LFRuntime.meta.name);
// 热重载友好的挂载:重新注入时自动清理旧节点
LFRuntime.mountHot("main-panel", (hot) => {
const el = document.createElement("div");
// 头部声明了 `// @resource panel ./panel.html` 时读到其内容;未声明的别名返回空串
const html = LFRuntime.getResource("panel");
if (html !== "") {
el.innerHTML = html;
} else {
el.textContent = "panel";
}
document.body.appendChild(el);
hot.onDispose(() => el.remove());
});
// 注入样式:同一 styleKey 重复调用会替换而非叠加
LFRuntime.appendStyle(".lf-btn { color: red; }", "theme");