Author SHA1 Message Date
Rick Ye 557887c1b3 revert(browser): 移除「导入本机浏览器登录态」功能(保留代理设置) 2026-09-13 02:04:55 +08:00
Rick Ye 573ac5bcde feat(browser): Cookie 备份导入——配套扩展走 chrome.cookies 官方明文通道 2026-09-13 01:22:21 +08:00
Rick Ye 2efcd8e346 fix(browser): node:sqlite 的 BLOB 是裸 Uint8Array,Cookie 解密前必须转 Buffer 2026-09-13 01:13:46 +08:00
Rick Ye d8c20022d8 fix(browser): 源 Cookie 的 expires_utc 换算移入 SQL,避开 int64 越界 2026-09-13 01:07:29 +08:00
Rick Ye 5cf13f4396 fix(browser): DPAPI 解密前先剥掉解码后 5 字节 DPAPI 前缀 2026-09-13 00:43:14 +08:00
Rick Ye 06a316b6d1 fix(browser): 导入彻底无感——分区轮换替代文件热替换,搁置/重启路径全部删除 2026-09-13 00:34:50 +08:00
Rick Ye 95044c3a15 fix(browser): 导入被搁置态的操作收纳——提示条在上,按钮集中底部一行 2026-09-11 19:08:02 +08:00
Rick Ye 09cc5bf648 feat(browser): 导入热应用——Cookie 解密注入 + 内核热重启,不再需要重启应用 2026-09-11 19:00:58 +08:00
Rick Ye d0b97f16b2 feat(browser): 被搁置的导入提供「重启并完成导入」一键按钮 2026-09-11 18:33:40 +08:00
Rick Ye 6259a2bba6 fix(browser): 占用转待办路径误删暂存目录导致 ENOENT 2026-09-11 18:18:04 +08:00
Rick Ye cf2cc1cb77 fix(browser): 导入 EPERM 修复——两段式迁移 + 分区占用时转待办重启生效 2026-09-11 18:03:03 +08:00
Rick Ye b1405ee71f feat(settings/plugins): 「全部」作用域 + 浏览器独立图标 + 浏览器设置页文案精简 2026-09-11 17:53:35 +08:00
Rick Ye 70930e4952 feat(browser): 浏览器插件行补「入口」开关(可固定设置页一级导航) 2026-09-11 17:46:28 +08:00
Rick Ye 4eb78ebc48 feat(browser): 浏览器插件二级设置页(代理 + 登录态导入) 2026-09-11 17:14:36 +08:00
Rick Ye b6e6031415 feat(browser): 受管浏览器代理设置(profile 导入已有实现,本轮补全代理) 2026-09-11 17:02:11 +08:00
Rick Ye 6507948f01 fix(sidebar): 折叠态「会话」切换图标与导航图标列等距 2026-09-11 16:44:37 +08:00
Rick Ye d8c1097b9d fix(sidepanel): 拖拽宽度把手时不再选中其他区域文字 2026-09-11 16:41:22 +08:00
Rick Ye 038339f2fe fix(browser): Agent 调用浏览器工具自动展开面板 + 页面区让出拖拽把手 2026-09-11 16:25:25 +08:00
Rick Ye 403b3b3ca1 fix(plugins): 悬浮胶囊药丸宽度收至 88px(文本约两字截断) 2026-09-11 16:15:34 +08:00
Rick Ye c117385d01 fix(plugins): 悬浮胶囊 ⤡ 收起按钮贴面板最右缘(区域头仍收拢左侧) 2026-09-11 16:13:40 +08:00
Rick Ye a052b6a2c4 fix(plugins): 悬浮胶囊细节修正——药丸收窄加高、面板高度自适应、区域头控件收拢 2026-09-11 16:08:56 +08:00
Rick Ye 781a6c8beb fix(plugins): 悬浮胶囊三点修正——位置/面板头/分组手风琴 2026-09-11 16:00:48 +08:00
Rick Ye ad16ed7239 feat(plugins): 悬浮状态胶囊(B3 落地,复刻 ZCode 药丸⇄面板二态) 2026-09-11 15:49:50 +08:00
Rick Ye 1326f9b672 fix(settings): 插件二级页头部补层级——面包屑导航层 + 大标题 + 元数据行 2026-09-11 15:29:24 +08:00
Rick Ye 335a08454a feat(settings): 插件二级页顶部改面包屑导航(shadcn Breadcrumb 语义) 2026-09-11 15:17:21 +08:00
Rick Ye 9b451c8c25 docs(plugins): 依赖与存储文档(L8/L9)+ 检验器入库并升级契约 0.3.0 规则 2026-09-11 15:00:59 +08:00
Rick Ye 1cb2b554c0 feat(plugins): 依赖互调 + SQLite 存储,契约 0.3.0 2026-09-11 14:56:30 +08:00
Rick Ye e958784d10 docs: 新增常见错误速查页(症状→原因→修复)+ 明确重载宿主级粒度;L4/L7/索引交叉引用 2026-09-11 13:49:34 +08:00
Rick Ye 5ee00bd2c4 feat(ui-refactor 4/4): 表单字段切 bg-field+inset 阴影(替代描边风,宿主/插件组件库统一);流式光标常亮对齐 StreamText 2026-09-11 13:38:23 +08:00
Rick Ye 1a7d79739d feat(ui-refactor 3/4): 聊天消息进场 fade-up(原版强缓动);审批卡/Composer/ToolChips 经 token 切换自动对齐原版规格 2026-09-11 12:06:21 +08:00
Rick Ye b0a59cdadf feat(ui-refactor 2/4): DESIGN.md 重写为 beautifului 标准(token/圆角/阴影/动效/字号体系);插件组件库 Button 对齐 pill 体系 2026-09-11 12:00:46 +08:00
Rick Ye a95b775120 chore: 移除临时文件并忽略 2026-09-11 11:58:52 +08:00
Rick Ye 60fc9b6f4f feat(ui-refactor 1/4): token 基座对齐 beautifului.dev foundation 原版——蓝色 accent、ring+分层阴影、圆角体系 6/8/10/14、强缓动曲线、暗色专属 ring;Button 恢复 pill 体系 2026-09-11 11:58:27 +08:00
Rick Ye 665f848c93 feat: 胶囊详情面板复刻 ZCode Git 工具——头部标题+关闭、分组、行尾彩色数值(新增 success tone);撤下聊天区浮动圆钮(场景并入顶栏面板) 2026-09-11 11:41:59 +08:00
Rick Ye 200b9877ec style: 顶栏胶囊放回标题旁边(保留中性降噪样式) 2026-09-11 11:34:49 +08:00
Rick Ye ad1f9e76e5 style: 状态胶囊降噪重构——中性底+tone 色点;B2 右对齐标题栏;B3 浮动钮挪到聊天列右上角向下展开 2026-09-11 11:33:08 +08:00
Rick Ye 9998bb7a0c style: 重载按钮与开关同规格(h-5 w-9),右缘对齐 2026-09-11 11:28:38 +08:00
Rick Ye d2fd6807aa feat: 聊天区浮动胶囊(B3)——与 B2 同源数据,折叠圆钮+计数徽标,展开纵向胶囊堆栈(detail 向上弹层/深链同顶栏) 2026-09-11 11:27:49 +08:00
Rick Ye 8866438005 style: 插件行三控件统一行内小号规格——开关 h-5 w-9、重载按钮 size-5 2026-09-11 11:22:59 +08:00
Rick Ye ef07c2db2c fix: 侧栏插件图标对齐宿主灰阶(text-ink-2);插件行右列重排——入口/启动/重载三行,文案改「启动/关闭」,重载为 lucide 刷新按钮 2026-09-11 11:20:08 +08:00
Rick Ye f1cbe764bb feat: 侧边面板改平铺标签条——所有打开的 tab 一起展示;内容区统一 bg-page(浏览器/插件面板背景一致) 2026-09-11 11:15:14 +08:00
Rick Ye 6098cf678a feat: 插件图标规范化——manifest/胶囊 icon 统一 lucide 图标名(白名单 101 个),未知名回落拼图图标;内联 svg 仅旧兼容 2026-09-11 11:11:40 +08:00
Rick Ye 7103675db8 feat: 状态胶囊升级——顶栏紧凑状态条 + 点击展开宿主渲染详情弹层(StatusItem.icon/detail:分组/行/徽标/行内深链) 2026-09-11 11:02:44 +08:00
Rick Ye 1edaf17623 fix: 返回按钮去边框;多行文本框全宽去等宽字体;ComingSoon 兜底误入插件设置页 2026-09-11 10:56:42 +08:00
Rick Ye 823dd26a96 fix: 插件设置页加返回按钮;修复 string/number 输入框 w-full 覆盖定宽导致 label 被挤成竖排 2026-09-11 10:52:11 +08:00
Rick Ye aadd3adb2e fix: 恢复二级设置页入口——点击插件行直达该插件配置页(有设置表单且已启动可点,开关区不穿透) 2026-09-11 10:48:23 +08:00
Rick Ye 1c8e9816fe style: 插件行控件两行布局——已启动/已关闭+启动开关、入口+入口开关;面板描述精简为「扩展插件」 2026-09-11 10:45:03 +08:00
Rick Ye 835b910d42 feat: 插件检验器 plugin:check + L6/L7 教程 + 模板接入组件库——性能与样式标准成为机器关卡 2026-09-11 10:11:55 +08:00
Rick Ye f77942ee2b feat: 插件性能护栏 + 公共组件库——激活/装载限时兜底、renderer 装载三态呈现、@aso/plugin-sdk/ui、withTimeout/withRetry 2026-09-11 09:56:26 +08:00
Rick Ye 4ed31a34fc docs: 渐进式插件教程(L0→L5)+ DESIGN.md 视觉规范 + plugin-template 脚手架 2026-09-11 02:18:01 +08:00
Rick Ye 8e4b5496e8 feat: 统一日志系统——结构化滚动文件 + api.log 插件通道 + 73 个 IPC 通道追踪 + pi 事件/渲染层错误归档 2026-09-11 02:12:40 +08:00
Rick Ye 2ad1743e10 feat: 插件声明式设置体系——registerSettings schema + 标准控件表单 + 一级导航入口开关 2026-09-11 01:45:32 +08:00
Rick Ye 8349db9947 refactor: 核心插件与插件页合并——插件页按 内置/全局/项目 三作用域统一管理 2026-09-11 01:18:38 +08:00
Rick Ye 3f0a2a975c style: 插件页去掉「商店」概念——userData 来源并入全局展示 2026-09-11 01:14:44 +08:00
Rick Ye 538c25ab37 fix: renderer 模块改经 IPC+Blob URL 装载(绕开 dev http 源 CORS);插件页改为全局/项目作用域切换 2026-09-11 01:11:18 +08:00
Rick Ye 71c91563e8 feat: 设置「插件」页落地——外置插件按来源分组(商店/项目/全局)展示与启停 2026-09-11 01:03:28 +08:00
Rick Ye 8b94d42930 feat: 外置插件一等公民——entry.renderer 宿主内 React 组件(.pi/extensions 一等插件源 + 开发版门控) 2026-09-11 00:46:52 +08:00
Rick Ye 52f93ddc84 perf: 去掉设置弹窗遮罩的 backdrop-blur,修复弹窗内滚动掉帧 2026-09-10 18:39:08 +08:00
Rick Ye a245952417 style: 去掉系统提示词页顶部只读说明文案 2026-09-10 18:17:16 +08:00
Rick Ye d2381488a5 style: 精简核心插件页文案 2026-09-10 18:14:24 +08:00
Rick Ye fdb5fe2ade feat: 技能/命令设置页新增启停开关——按资源粒度控制 Agent 能力 2026-09-10 17:44:12 +08:00
Rick Ye e8cca122f8 feat: 基础设置新增「核心插件」页——内置插件按需启停 2026-09-10 17:09:19 +08:00
Rick Ye 903d393e09 feat: 浏览器新增 browser_find_text / browser_click_point——AX 快照够不到的元素也能定位点击 2026-09-10 15:38:45 +08:00
Rick Ye 0bae7cdc22 feat: Windows 环境优先使用 PowerShell 工具替代 bash 2026-09-10 14:18:47 +08:00
Rick Ye cafb6f34de perf: 会话统计增量聚合——emitStats/buildContextInfo 不再每轮全量遍历 2026-09-10 12:27:33 +08:00
Rick Ye 8216d70fcf refactor: rebuildRuntime 语义化——插件重载透明化,工作区切换全量切换 2026-09-10 12:03:16 +08:00
Rick Ye 4c5a379ca1 fix: 工作过程误折叠/反复横跳——ready-to-show 二次触发不得重置回合状态 2026-09-10 11:43:37 +08:00
Rick Ye ddecaef4ef fix: 消息级错误可见化——API 失败(如 402 余额耗尽)不再渲染为空白回合 2026-09-10 10:58:30 +08:00
Rick Ye 8df8a0e3d1 fix: 回合活跃边界修正——本地用户消息为硬边界,历史回合不再被误激活展开 2026-09-10 10:28:57 +08:00
103 changed files with 7708 additions and 1262 deletions
+1 -1
View File
@@ -10,5 +10,5 @@ release/
preview-shots/ preview-shots/
*.tsbuildinfo *.tsbuildinfo
capture-log.txt capture-log.txt
scripts/
preview/ preview/
"tmp-foundation.css"
+23 -1
View File
@@ -23,10 +23,28 @@ function copyTokensCss(): Plugin {
} }
} }
/** 构建期把插件存储 worker(免打包纯 ESM,只依赖 node 内置模块)拷到 out/main/ 旁:
* 主进程按 import.meta.url 定位它——必须与主进程产物同目录 */
function copyStorageWorker(): Plugin {
return {
name: 'aso-copy-storage-worker',
closeBundle() {
const src = resolve(__dirname, 'src/main/plugins/storage-worker.mjs')
const outDir = resolve(__dirname, 'out/main')
try {
mkdirSync(outDir, { recursive: true })
copyFileSync(src, resolve(outDir, 'storage-worker.mjs'))
} catch (err) {
this.warn(`storage-worker.mjs 拷贝失败: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
}
export default defineConfig({ export default defineConfig({
main: { main: {
// pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持) // pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持)
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin(), copyStorageWorker()],
build: { build: {
rollupOptions: { rollupOptions: {
// electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载, // electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载,
@@ -74,6 +92,10 @@ export default defineConfig({
// 允许 dev server 读取工作区源码包(@aso/plugin-sdk 从 packages/ 链接进来) // 允许 dev server 读取工作区源码包(@aso/plugin-sdk 从 packages/ 链接进来)
fs: { allow: [resolve(__dirname, '../..')] } fs: { allow: [resolve(__dirname, '../..')] }
}, },
optimizeDeps: {
// sdk 是源码形态(exports 指向 .ts/.tsx),必须走源码 transform 而非预打包
exclude: ['@aso/plugin-sdk']
},
plugins: [react(), tailwindcss(), copyTokensCss()], plugins: [react(), tailwindcss(), copyTokensCss()],
resolve: { resolve: {
alias: { alias: {
@@ -1,7 +1,11 @@
import { getLogger } from '../logger'
const log = getLogger('browser')
import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron' import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { rm as rmAsync } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import type { BrowserImportInfo, BrowserTabInfo, BrowserUiState } from '@shared/types' import type { BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types'
import { import {
BrowserCdpTimeoutError, BrowserCdpTimeoutError,
BROWSER_OBSERVE_TIMEOUT_MS, BROWSER_OBSERVE_TIMEOUT_MS,
@@ -12,6 +16,7 @@ import {
import { import {
assertSafeBrowserUrl, assertSafeBrowserUrl,
browserObservationNameLimit, browserObservationNameLimit,
MAX_BROWSER_FIND_TEXT_RESULTS,
NAMELESS_ALLOWED_ROLES, NAMELESS_ALLOWED_ROLES,
prioritizeBrowserObservationCandidates, prioritizeBrowserObservationCandidates,
resolveBrowserDestination, resolveBrowserDestination,
@@ -26,8 +31,10 @@ import {
* 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。 * 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。
* - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制 * - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。 * + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
* - 登录态隔离在 persist:aso-browser 分区;本地浏览器配置导入由 browser-profile.ts * - 登录态隔离在 persist:aso-browser 分区,随分区持久保存。
* 直接迁移分区目录数据(Cookies/Local Storage + 密钥移植)。 * - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则
* (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json,
* 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。
*/ */
const PARTITION = 'persist:aso-browser' const PARTITION = 'persist:aso-browser'
@@ -60,11 +67,13 @@ interface TabRecord {
commandTail: Promise<unknown> commandTail: Promise<unknown>
lastLayoutRevision: number lastLayoutRevision: number
attached: boolean attached: boolean
/** 最近一次截图的 像素/DIP 比例(browser_click_point 换算截图坐标用) */
lastShotScale: number
} }
interface BrowserSettings { interface BrowserSettings {
riskAcknowledged: boolean riskAcknowledged: boolean
imported?: BrowserImportInfo | null proxy?: BrowserProxySettings
} }
export interface BrowserObservation { export interface BrowserObservation {
@@ -75,6 +84,23 @@ export interface BrowserObservation {
elements: Array<{ ref: string; role: string; name: string; editable: boolean }> elements: Array<{ ref: string; role: string; name: string; editable: boolean }>
} }
/** browser_find_text 的候选描述。rect 为视口 CSS 像素(iframe 内元素已换算为顶层页面坐标) */
export interface BrowserFindTextCandidate {
index: number
tagName: string
text: string
rect: { x: number; y: number; w: number; h: number }
context: string
/** true = 位于 iframe 内(不能直接注册 ref 点击,需改用坐标点击) */
iframe?: boolean
}
export interface BrowserFindTextResult extends BrowserObservation {
matched: number
picked?: number
candidates?: BrowserFindTextCandidate[]
}
export interface BrowserWaitCondition { export interface BrowserWaitCondition {
kind: 'url' | 'text' | 'selector' kind: 'url' | 'text' | 'selector'
value: string value: string
@@ -88,6 +114,36 @@ function textValue(v: unknown): string {
return '' return ''
} }
/** 校验/规范化代理输入(fixed 模式必须有可解析的 host:port 规则;其余模式丢弃规则字段) */
function normalizeProxy(input: BrowserProxySettings): BrowserProxySettings {
if (input.mode !== 'fixed') return { mode: input.mode }
const entries = (input.rules ?? '')
.split(';')
.map((s) => s.trim())
.filter(Boolean)
if (entries.length === 0) {
throw new Error('手动代理需要填写规则,如 127.0.0.1:7890(多协议用分号分隔,如 http=127.0.0.1:7890;https=127.0.0.1:7890)。')
}
for (const entry of entries) {
const hostPort = entry.includes('://')
? entry.slice(entry.indexOf('://') + 3)
: entry.includes('=')
? entry.slice(entry.indexOf('=') + 1)
: entry
const colon = hostPort.lastIndexOf(':')
const port = colon >= 0 ? hostPort.slice(colon + 1) : ''
if (!hostPort || colon < 0 || !/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
throw new Error(`代理规则「${entry}」无效:应为 host:port(如 127.0.0.1:7890)。`)
}
}
const bypass = (input.bypass ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join(',')
return { mode: 'fixed', rules: entries.join(';'), bypass: bypass || undefined }
}
function isEditableAxNode(node: Record<string, unknown>): boolean { function isEditableAxNode(node: Record<string, unknown>): boolean {
const role = textValue(node.role).toLowerCase() const role = textValue(node.role).toLowerCase()
if (role === 'textfield' || role === 'textbox' || role === 'searchbox') return true if (role === 'textfield' || role === 'textbox' || role === 'searchbox') return true
@@ -119,6 +175,140 @@ const KEY_EVENTS: Record<string, { key: string; code: string; vk: number }> = {
space: { key: ' ', code: 'Space', vk: 32 } space: { key: ' ', code: 'Space', vk: 32 }
} }
/** 页面内文本/选择器查找器(宿主固定函数,参数经 JSON 注入,非任意脚本)。
* 遍历主文档 + 开放 shadow DOM + 同源 iframe(深度受限),按元素自身直接可见文本或
* CSS 选择器匹配。返回纯可序列化数据(results 元数据 + diag 诊断 + pick 模式打标结果)。
* 背景:纯 div/span + JS 点击的元素(商品规格等)不在 AX 快照里,ref 体系够不到它们。
* 注意:结果回传只用 returnByValue 序列化——Runtime.callFunctionOn 跨命令持有
* RemoteObject 引用在 Electron debugger 下不可靠(this 绑定丢失,恒返回 undefined)。 */
const FIND_TEXT_FINDER = String.raw`function(input) {
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim()
const results = []
const docs = []
let visitedTotal = 0
const collectDocs = (doc, depth) => {
if (!doc || docs.indexOf(doc) >= 0 || depth > 3) return
docs.push(doc)
let frames = []
try { frames = Array.prototype.slice.call(doc.querySelectorAll('iframe')) } catch (err) {}
for (const frame of frames) {
try { collectDocs(frame.contentDocument, depth + 1) } catch (err) {}
}
}
collectDocs(document, 0)
if (input.selector) {
try { document.querySelector(input.selector) } catch (err) {
return { error: '选择器语法非法' }
}
}
const ownText = (el) => {
let text = ''
for (let node = el.firstChild; node; node = node.nextSibling) {
if (node.nodeType === 3) text += node.nodeValue
}
return norm(text)
}
const contextOf = (el) => {
let parent = el.parentElement
while (parent) {
const text = norm(parent.textContent)
if (text) return text.slice(0, 60)
parent = parent.parentElement
}
return ''
}
const visible = (el) => {
try {
const rect = el.getBoundingClientRect()
if (rect.width < 1 || rect.height < 1) return false
const view = el.ownerDocument.defaultView
if (!view) return false
const style = view.getComputedStyle(el)
return style.visibility !== 'hidden' && style.pointerEvents !== 'none'
} catch (err) {
return false
}
}
const toTopCoords = (el) => {
let doc = el.ownerDocument
let x = 0
let y = 0
let guard = 0
while (doc && doc !== document && guard++ < 5) {
const findHost = (d) => {
let frames = []
try { frames = Array.prototype.slice.call(d.querySelectorAll('iframe')) } catch (err) {}
for (const frame of frames) {
if (frame.contentDocument === doc) return frame
const nested = findHost(frame.contentDocument)
if (nested) return nested
}
return null
}
const host = findHost(document)
if (!host) break
const hostRect = host.getBoundingClientRect()
x += hostRect.x
y += hostRect.y
doc = host.ownerDocument
}
return { x, y }
}
const hits = []
const walk = (root) => {
let iter = null
try { iter = root.createTreeWalker(root, NodeFilter.SHOW_ELEMENT) } catch (err) { return }
let visited = 0
let node
while ((node = iter.nextNode())) {
if (++visited > 20000) break
visitedTotal++
const el = node
if (el.shadowRoot) walk(el.shadowRoot)
let hit = false
let label = ''
if (input.selector) {
try { hit = el.matches(input.selector) } catch (err) { hit = false }
if (hit) {
label = ownText(el)
if (!label) {
try { label = norm(el.getAttribute('aria-label') || el.getAttribute('title') || '') } catch (err) {}
}
}
} else {
label = ownText(el)
if (label) hit = input.contains ? label.indexOf(input.text) >= 0 : label === input.text
}
if (hit && visible(el)) hits.push({ el, label })
}
}
for (const doc of docs) {
if (doc.documentElement) walk(doc)
}
if (input.pickIndex !== undefined && input.pickIndex !== null) {
const hit = hits[input.pickIndex - 1]
if (!hit) return { ok: false, diag: { docs: docs.length, visited: visitedTotal, hits: hits.length, url: String(location.href) } }
hit.el.setAttribute('data-aso-find', input.tagValue)
const rect = hit.el.getBoundingClientRect()
const offset = hit.el.ownerDocument === document ? { x: 0, y: 0 } : toTopCoords(hit.el)
return { ok: true, tagged: hit.label.slice(0, 80), rect: { x: Math.round(rect.x + offset.x), y: Math.round(rect.y + offset.y) }, diag: { docs: docs.length, visited: visitedTotal, hits: hits.length, url: String(location.href) } }
}
for (const hit of hits.slice(0, input.limit)) {
const rect = hit.el.getBoundingClientRect()
const offset = hit.el.ownerDocument === document ? { x: 0, y: 0 } : toTopCoords(hit.el)
results.push({
index: results.length + 1,
tagName: hit.el.tagName.toLowerCase(),
text: hit.label.slice(0, 80),
rect: { x: Math.round(rect.x + offset.x), y: Math.round(rect.y + offset.y), w: Math.round(rect.width), h: Math.round(rect.height) },
context: contextOf(hit.el),
iframe: hit.el.ownerDocument !== document
})
}
const diag = { docs: docs.length, visited: visitedTotal, hits: hits.length, url: String(location.href), readyState: document.readyState, nodeFilter: typeof NodeFilter }
return { results, diag }
}`
export class BrowserController { export class BrowserController {
private owner: BrowserWindow | null = null private owner: BrowserWindow | null = null
private browserSession: Session private browserSession: Session
@@ -132,19 +322,24 @@ export class BrowserController {
private closedTabs: Array<{ title: string; url: string; closedAt: number }> = [] private closedTabs: Array<{ title: string; url: string; closedAt: number }> = []
constructor() { constructor() {
this.browserSession = electronSession.fromPartition(PARTITION) this.settings = this.loadSettings()
this.browserSession = this.createBrowserSession()
this.cleanupLegacyImportArtifacts()
}
/** 创建受管分区会话并挂上固定策略(UA、权限、下载、代理) */
private createBrowserSession(): Session {
const session = electronSession.fromPartition(PARTITION)
// 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识 // 诚实的 UA:保留 Chromium token 保证站点兼容,去掉 Electron 标识
this.browserSession.setUserAgent( session.setUserAgent(session.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`))
this.browserSession.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`)
)
// 网页权限请求(摄像头/定位等)默认拒绝 // 网页权限请求(摄像头/定位等)默认拒绝
this.browserSession.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)) session.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
// 下载留在受管浏览器内,落到系统下载目录(重名自动加序号) // 下载留在受管浏览器内,落到系统下载目录(重名自动加序号)
this.browserSession.on('will-download', (_event, item) => { session.on('will-download', (_event, item) => {
const dir = app.getPath('downloads') const dir = app.getPath('downloads')
const base = item.getFilename() || 'download' const base = item.getFilename() || 'download'
const dot = base.lastIndexOf('.') const dot = base.lastIndexOf('.')
const stem = dot > 0 ? base.slice(0, dot) : base const stem = dot > 0 ? base.slice(0, dot) : ''
const ext = dot > 0 ? base.slice(dot) : '' const ext = dot > 0 ? base.slice(dot) : ''
let target = join(dir, base) let target = join(dir, base)
for (let i = 1; existsSync(target) && i < 100; i++) { for (let i = 1; existsSync(target) && i < 100; i++) {
@@ -152,7 +347,11 @@ export class BrowserController {
} }
item.setSavePath(target) item.setSavePath(target)
}) })
this.settings = this.loadSettings() // 启动即应用持久化的代理设置(setProxy 异步;失败不阻塞浏览器初始化,日志可查)
this.applyProxyTo(session, this.proxySettings()).catch((err: unknown) => {
log.error('启动时代理设置应用失败:', { err })
})
return session
} }
// ---- 设置持久化 ---- // ---- 设置持久化 ----
@@ -164,12 +363,16 @@ export class BrowserController {
private loadSettings(): BrowserSettings { private loadSettings(): BrowserSettings {
try { try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings> const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
const proxy = raw.proxy
return { return {
riskAcknowledged: raw.riskAcknowledged === true, riskAcknowledged: raw.riskAcknowledged === true,
imported: raw.imported ?? null proxy:
proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system')
? proxy
: { mode: 'system' }
} }
} catch { } catch {
return { riskAcknowledged: false } return { riskAcknowledged: false, proxy: { mode: 'system' } }
} }
} }
@@ -177,24 +380,70 @@ export class BrowserController {
try { try {
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8') writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8')
} catch (err) { } catch (err) {
console.error('[browser] save settings failed:', err) log.error('save settings failed:', { err: err })
} }
} }
/** 配置导入完成后由 browser-profile.ts 回写摘要 */ /** 历史遗留清理:删除已下线的「登录态导入」功能留下的轮换分区目录与暂存
setImportedInfo(info: BrowserImportInfo | null): void { * (aso-browser-g* / .import-staging-* / aso-browser.pending-import)。
this.settings.imported = info * 控制器创建时无人持有这些目录的句柄;失败只记日志,下次启动再试。 */
private cleanupLegacyImportArtifacts(): void {
const root = join(app.getPath('userData'), 'Partitions')
if (!existsSync(root)) return
const stale: string[] = []
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (
entry.name.startsWith('.import-staging-') ||
entry.name === 'aso-browser.pending-import' ||
/^aso-browser-g\d+$/.test(entry.name)
) {
stale.push(entry.name)
}
}
if (stale.length === 0) return
void (async () => {
for (const name of stale) {
try {
await rmAsync(join(root, name), { recursive: true, force: true, maxRetries: 1 })
} catch (err) {
log.warn('清理遗留导入目录失败,下次启动重试', { name, err: err instanceof Error ? err.message : String(err) })
}
}
log.info('cleaned legacy import artifacts', { stale })
})()
}
// ---- 代理设置 ----
private proxySettings(): BrowserProxySettings {
return this.settings.proxy ?? { mode: 'system' }
}
getProxySettings(): BrowserProxySettings {
return this.proxySettings()
}
/** 应用到指定分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */
private async applyProxyTo(session: Session, proxy: BrowserProxySettings): Promise<void> {
await session.setProxy(
proxy.mode === 'fixed'
? { mode: 'fixed_servers', proxyRules: proxy.rules ?? '', proxyBypassRules: proxy.bypass }
: proxy.mode === 'direct'
? { mode: 'direct' }
: { mode: 'system' }
)
await session.closeAllConnections()
}
/** 更新并持久化代理设置:先应用成功才落盘(失败抛错给 UI,原设置保持不变) */
async setProxySettings(input: BrowserProxySettings): Promise<BrowserProxySettings> {
const next = normalizeProxy(input)
await this.applyProxyTo(this.browserSession, next)
this.settings.proxy = next
this.saveSettings() this.saveSettings()
this.emitState() this.emitState()
} return next
get importedInfo(): BrowserImportInfo | null {
return this.settings.imported ?? null
}
/** 当前是否有活跃标签页(导入配置前必须没有) */
get hasTabs(): boolean {
return this.tabs.size > 0
} }
// ---- 窗口与状态广播 ---- // ---- 窗口与状态广播 ----
@@ -238,7 +487,7 @@ export class BrowserController {
activeTabId: this.activeTabId, activeTabId: this.activeTabId,
recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })), recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })),
trace: this.traceText, trace: this.traceText,
imported: this.settings.imported ?? null proxy: this.proxySettings()
} }
} }
@@ -252,7 +501,7 @@ export class BrowserController {
try { try {
fn(state) fn(state)
} catch (err) { } catch (err) {
console.error('[browser] state listener error:', err) log.error('state listener error:', { err: err })
} }
} }
} }
@@ -299,16 +548,19 @@ export class BrowserController {
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。') if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。')
if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`) if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`)
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
// popup 的 viewOptions 不一定带 partition(会导致弹窗落入默认会话),统一强制受管分区
const view = new WebContentsView( const view = new WebContentsView(
viewOptions ?? { viewOptions
webPreferences: { ? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition: PARTITION } }
partition: PARTITION, : {
nodeIntegration: false, webPreferences: {
contextIsolation: true, partition: PARTITION,
sandbox: true, nodeIntegration: false,
webSecurity: true contextIsolation: true,
} sandbox: true,
} webSecurity: true
}
}
) )
const tab: TabRecord = { const tab: TabRecord = {
tabId, tabId,
@@ -321,7 +573,8 @@ export class BrowserController {
generation: 0, generation: 0,
commandTail: Promise.resolve(), commandTail: Promise.resolve(),
lastLayoutRevision: 0, lastLayoutRevision: 0,
attached: false attached: false,
lastShotScale: 1
} }
view.setVisible(false) view.setVisible(false)
@@ -384,7 +637,7 @@ export class BrowserController {
try { try {
view.webContents.debugger.attach('1.3') view.webContents.debugger.attach('1.3')
} catch (err) { } catch (err) {
console.warn('[browser] CDP attach 失败:', err) log.warn('CDP attach 失败:', { err: err })
} }
this.tabs.set(tabId, tab) this.tabs.set(tabId, tab)
if (!this.activeTabId) this.activeTabId = tabId if (!this.activeTabId) this.activeTabId = tabId
@@ -453,7 +706,7 @@ export class BrowserController {
return this.buildState() return this.buildState()
} }
/** 关闭整个受管浏览器(销毁全部标签;导入配置前也会走这里) */ /** 关闭整个受管浏览器(销毁全部标签;分区会话与登录态保留) */
close(): BrowserUiState { close(): BrowserUiState {
for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId) for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId)
this.agentTabId = null this.agentTabId = null
@@ -480,7 +733,7 @@ export class BrowserController {
tab.view.setVisible(false) tab.view.setVisible(false)
} }
} catch (err) { } catch (err) {
console.error('[browser] apply layout failed:', err) log.error('apply layout failed:', { err: err })
} }
} }
@@ -534,7 +787,7 @@ export class BrowserController {
const debuggerClient = tab.view.webContents.debugger const debuggerClient = tab.view.webContents.debugger
if (debuggerClient.isAttached()) debuggerClient.detach() if (debuggerClient.isAttached()) debuggerClient.detach()
debuggerClient.attach('1.3') debuggerClient.attach('1.3')
console.warn(`[browser] ${method} 超时,已重连调试通道`) log.warn(`CDP ${method} timeout, reattached`)
} catch { } catch {
// 页面已关闭 // 页面已关闭
} }
@@ -712,6 +965,92 @@ export class BrowserController {
}) })
} }
/** 按可见文本/CSS 选择器在 DOM 中直查(不依赖无障碍树):命中后注册为普通 ref,
* browser_click 等后续操作照常使用。多命中且未指定 index 时只返回候选列表不发 ref;
* 仅在真正注册 ref 时才作废旧 ref(与 observe/find 的失效语义一致)。 */
async findText(
query: { text?: string; selector?: string; contains?: boolean; index?: number },
tabId?: string
): Promise<BrowserFindTextResult> {
const text = query.text?.trim().slice(0, 200) ?? ''
const selector = query.selector?.trim().slice(0, 500) ?? ''
if ((text ? 1 : 0) + (selector ? 1 : 0) !== 1) throw new Error('text 与 selector 必须且只能提供一个。')
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
this.agentTabId = tab.tabId
return this.runAgentOp(tab, async () => {
const baseInput = { text: text || undefined, selector: selector || undefined, contains: query.contains === true, limit: MAX_BROWSER_FIND_TEXT_RESULTS }
const payload = await this.evaluateOnPage(tab, `((${FIND_TEXT_FINDER})(${JSON.stringify(baseInput)}))`) as
| { error?: string; results?: unknown[]; diag?: unknown }
| null
if (!payload || typeof payload !== 'object') throw new Error('页面定位结果异常,请重试。')
if (payload.error) throw new Error(`定位失败:${payload.error}`)
const diag = payload.diag
const candidates = (Array.isArray(payload.results) ? payload.results : []).filter(
(c): c is BrowserFindTextCandidate =>
!!c && typeof c === 'object' && typeof (c as BrowserFindTextCandidate).tagName === 'string'
)
if (candidates.length === 0) {
this.trace(tab, 'find_text', `未找到 ${text || selector}`)
return {
tabId: tab.tabId, url: tab.url, title: tab.title, generation: tab.generation,
matched: 0, elements: [], candidates: [], ...(diag !== undefined ? { diag } : {})
}
}
const picked = query.index ?? 1
if (!Number.isInteger(picked) || picked < 1 || picked > candidates.length) {
throw new Error(`index 必须是 1 到 ${candidates.length} 之间的整数。`)
}
if (candidates.length > 1 && query.index === undefined) {
this.trace(tab, 'find_text', `「${text || selector}」命中 ${candidates.length} 处,等待指定 index`)
return {
tabId: tab.tabId, url: tab.url, title: tab.title, generation: tab.generation,
matched: candidates.length, elements: [], candidates, ...(diag !== undefined ? { diag } : {})
}
}
const candidate = candidates[picked - 1]
if (candidate.iframe === true) {
throw new Error('目标元素位于 iframe 内,无法直接注册 ref:请基于 browser_screenshot 用 browser_click_point 点击(候选列表 rect 已换算为页面顶层坐标)。')
}
// 选定阶段:给第 N 个命中打临时 data 属性,再经 DOM 域换成 backendNodeId。
// (DOM.querySelector 不穿 shadow DOM,shadow 内的命中会在下一步报"未找到",
// 报错信息会引导改用坐标点击)
const tagValue = Math.random().toString(36).slice(2, 10)
const pickPayload = await this.evaluateOnPage(
tab,
`((${FIND_TEXT_FINDER})(${JSON.stringify({ ...baseInput, pickIndex: picked, tagValue })}))`
) as { ok?: boolean; tagged?: string } | null
if (!pickPayload || pickPayload.ok !== true) {
throw new Error('页面已变化,目标元素不存在了,请重新调用 browser_find_text。')
}
const documentResponse = await this.cdp(tab, 'DOM.getDocument', { depth: 0 })
const rootId = (documentResponse.root as { nodeId?: number } | undefined)?.nodeId
if (typeof rootId !== 'number' || rootId <= 0) throw new Error('无法访问页面文档,请重试。')
const queryResponse = await this.cdp(tab, 'DOM.querySelector', {
nodeId: rootId,
selector: `[data-aso-find="${tagValue}"]`
})
const taggedNodeId = (queryResponse as { nodeId?: number }).nodeId
if (typeof taggedNodeId !== 'number' || taggedNodeId === 0) {
throw new Error('目标元素无法转换为可点击引用(可能位于 shadow DOM 内),请改用 browser_screenshot + browser_click_point。')
}
const describeResponse = await this.cdp(tab, 'DOM.describeNode', { nodeId: taggedNodeId })
const backendNodeId = (describeResponse.node as { backendNodeId?: number } | undefined)?.backendNodeId
await this.cdp(tab, 'DOM.removeAttribute', { nodeId: taggedNodeId, name: 'data-aso-find' }).catch(() => undefined)
if (typeof backendNodeId !== 'number' || backendNodeId <= 0) throw new Error('无法解析目标元素,请重新定位。')
const elements = this.buildRefs(tab, [
{ backendNodeId, role: candidate.tagName, name: candidate.text, editable: false }
])
this.trace(tab, 'find_text', `定位「${(candidate.text || candidate.tagName).slice(0, 40)}」→ ${elements[0]?.ref}(共 ${candidates.length} 处)`)
return {
tabId: tab.tabId, url: tab.url, title: tab.title, generation: tab.generation,
matched: candidates.length, picked,
elements,
candidates: candidates.length > 1 ? candidates : undefined
}
})
}
private async clickRef(tab: TabRecord, ref: string, generation: number): Promise<RefEntry> { private async clickRef(tab: TabRecord, ref: string, generation: number): Promise<RefEntry> {
const target = this.resolveRef(tab, ref) const target = this.resolveRef(tab, ref)
const { x, y } = await this.centerForRef(tab, ref, generation) const { x, y } = await this.centerForRef(tab, ref, generation)
@@ -762,6 +1101,40 @@ export class BrowserController {
}) })
} }
/** 截图坐标兜底点击:坐标以最近一次 browser_screenshot 图像的像素为准(按截图比例换算 DIP)。
* 仅供 observe/find/find_text 都无法定位时使用(canvas、封闭 shadow DOM、混淆页面)。 */
async clickPoint(x: number, y: number, tabId?: string): Promise<BrowserUiState> {
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) throw new Error('坐标必须是非负数。')
this.assertRiskAcknowledged()
const tab = this.getAgentTab(tabId)
this.agentTabId = tab.tabId
return this.runAgentOp(tab, async () => {
const bounds = tab.view.getBounds()
if (bounds.width <= 0 || bounds.height <= 0) throw new Error('浏览器面板当前不可见,无法按坐标点击。')
const scale = tab.lastShotScale > 0 ? tab.lastShotScale : 1
const dipX = x / scale
const dipY = y / scale
if (dipX >= bounds.width || dipY >= bounds.height) {
throw new Error(
`坐标越界:换算后 (${Math.round(dipX)}, ${Math.round(dipY)}) 超出页面 ${Math.round(bounds.width)}×${Math.round(bounds.height)},请基于最新截图重新取坐标。`
)
}
// 点击位置标记(宿主固定脚本,纯视觉反馈)
const marker =
'((p)=>{try{const d=document.createElement("div");d.style.cssText="position:fixed;left:"+(p.x-9)+"px;top:"+(p.y-9)+' +
'"px;width:18px;height:18px;border:2px solid #f59e0b;border-radius:50%;pointer-events:none;z-index:2147483647;box-sizing:border-box";' +
'document.documentElement.appendChild(d);setTimeout(()=>d.remove(),700);return true}catch(e){return false}})(' +
JSON.stringify({ x: dipX, y: dipY }) +
')'
await this.cdp(tab, 'Runtime.evaluate', { expression: marker, returnByValue: true }).catch(() => undefined)
await this.cdp(tab, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: dipX, y: dipY })
await this.cdp(tab, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: dipX, y: dipY, button: 'left', clickCount: 1 })
await this.cdp(tab, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: dipX, y: dipY, button: 'left', clickCount: 1 })
this.trace(tab, 'click_point', `点击截图坐标 (${Math.round(x)}, ${Math.round(y)})`)
return this.buildState()
})
}
async fill(ref: string, text: string, tabId?: string): Promise<BrowserUiState> { async fill(ref: string, text: string, tabId?: string): Promise<BrowserUiState> {
if (text.length > MAX_FILL_CHARS) throw new Error(`单次输入不能超过 ${MAX_FILL_CHARS} 个字符。`) if (text.length > MAX_FILL_CHARS) throw new Error(`单次输入不能超过 ${MAX_FILL_CHARS} 个字符。`)
this.assertRiskAcknowledged() this.assertRiskAcknowledged()
@@ -930,6 +1303,9 @@ export class BrowserController {
const tab = this.getAgentTab(tabId) const tab = this.getAgentTab(tabId)
return this.runAgentOp(tab, async () => { return this.runAgentOp(tab, async () => {
const image = await tab.view.webContents.capturePage() const image = await tab.view.webContents.capturePage()
// 记录 像素/DIP 比例,browser_click_point 换算截图坐标用(capturePage 按显示 DPR 放大)
const bounds = tab.view.getBounds()
if (bounds.width > 0) tab.lastShotScale = image.getSize().width / bounds.width
this.trace(tab, 'screenshot', '截取当前页面') this.trace(tab, 'screenshot', '截取当前页面')
return { tabId: tab.tabId, url: tab.url, title: tab.title, dataUrl: image.toDataURL() } return { tabId: tab.tabId, url: tab.url, title: tab.title, dataUrl: image.toDataURL() }
}) })
@@ -78,6 +78,9 @@ export const INTERACTIVE_AX_ROLES = new Set([
/** 无名称也值得返回的 role(依赖 role 本身可定位) */ /** 无名称也值得返回的 role(依赖 role 本身可定位) */
export const NAMELESS_ALLOWED_ROLES = new Set(['button', 'textfield', 'textbox', 'link', 'checkbox', 'combobox']) export const NAMELESS_ALLOWED_ROLES = new Set(['button', 'textfield', 'textbox', 'link', 'checkbox', 'combobox'])
/** browser_find_text 单次返回的候选上限(DOM 直查不受快照配额约束,巨页防爆量) */
export const MAX_BROWSER_FIND_TEXT_RESULTS = 40
export function resolveBrowserObserveMaxElements(requested?: number): number { export function resolveBrowserObserveMaxElements(requested?: number): number {
if (typeof requested !== 'number' || !Number.isFinite(requested)) return DEFAULT_BROWSER_OBSERVE_MAX_ELEMENTS if (typeof requested !== 'number' || !Number.isFinite(requested)) return DEFAULT_BROWSER_OBSERVE_MAX_ELEMENTS
return Math.min(MAX_BROWSER_OBSERVE_MAX_ELEMENTS, Math.max(MIN_BROWSER_OBSERVE_MAX_ELEMENTS, Math.round(requested))) return Math.min(MAX_BROWSER_OBSERVE_MAX_ELEMENTS, Math.max(MIN_BROWSER_OBSERVE_MAX_ELEMENTS, Math.round(requested)))
@@ -1,256 +0,0 @@
import { app } from 'electron'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { BrowserImportResult, BrowserProfileMeta, BrowserSource } from '@shared/types'
import { getBrowserController } from './browser-controller'
/**
* 本地浏览器配置导入(复用各网站登录态)。
*
* 实现思路(零解密依赖,纯文件迁移):
* - Local Storage / IndexedDB 在 Chromium 里是明文 leveldb,直接拷入受管分区即可生效。
* - Cookies 用 AES-GCM 加密,密钥经 OS 加密后存于浏览器根目录 Local State
* (os_crypt.encrypted_key)。Windows 上该密钥由 DPAPI(用户级)包裹——同一
* Windows 用户的任意进程都能解开,因此把这份密钥「移植」进本应用的 Local State,
* 本应用内的 Chromium 即可解密拷贝过来的 Cookies(Chrome 127+ 的 app-bound
* 加密 V20 除外,会明确提示不支持)。macOS/Linux 的密钥绑定钥匙串/KWallet,
* Cookies 无法直接迁移,仅迁移 Local Storage。
*/
/** Chromium 系浏览器的用户数据根目录(按平台) */
const SOURCE_ROOTS: Array<{ id: string; name: string; win: string; mac: string; linux: string }> = [
{
id: 'chrome',
name: 'Google Chrome',
win: join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome'),
linux: join(homedir(), '.config', 'google-chrome')
},
{
id: 'edge',
name: 'Microsoft Edge',
win: join(process.env.LOCALAPPDATA ?? '', 'Microsoft', 'Edge', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Microsoft Edge'),
linux: join(homedir(), '.config', 'microsoft-edge')
},
{
id: 'brave',
name: 'Brave',
win: join(process.env.LOCALAPPDATA ?? '', 'BraveSoftware', 'Brave-Browser', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'),
linux: join(homedir(), '.config', 'BraveSoftware', 'Brave-Browser')
},
{
id: 'chromium',
name: 'Chromium',
win: join(process.env.LOCALAPPDATA ?? '', 'Chromium', 'User Data'),
mac: join(homedir(), 'Library', 'Application Support', 'Chromium'),
linux: join(homedir(), '.config', 'chromium')
}
]
/** 受管浏览器分区目录(persist:aso-browser 的落盘位置) */
function partitionDir(): string {
return join(app.getPath('userData'), 'Partitions', 'aso-browser')
}
function sourceRoot(id: string): string {
const source = SOURCE_ROOTS.find((s) => s.id === id)
if (!source) return ''
return process.platform === 'win32' ? source.win : process.platform === 'darwin' ? source.mac : source.linux
}
/** 读取 profile 的展示名(Preferences 的 profile.name,失败时用目录名) */
function readProfileName(profileDir: string): string {
try {
const file = join(profileDir, 'Preferences')
if (statSync(file).size > 8 * 1024 * 1024) return profileDir.split(/[\\/]/).pop() || profileDir
const raw = JSON.parse(readFileSync(file, 'utf8')) as { profile?: { name?: string } }
return raw.profile?.name?.trim() || profileDir.split(/[\\/]/).pop() || profileDir
} catch {
return profileDir.split(/[\\/]/).pop() || profileDir
}
}
/** 检测本机可导入的浏览器与 profile 列表 */
export function detectBrowserSources(): BrowserSource[] {
const sources: BrowserSource[] = []
for (const source of SOURCE_ROOTS) {
const root = sourceRoot(source.id)
if (!root || !existsSync(root)) continue
const profiles: BrowserProfileMeta[] = []
try {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (entry.name.startsWith('.') || entry.name === 'System Profile' || entry.name === 'Guest Profile') continue
const profileDir = join(root, entry.name)
if (!existsSync(join(profileDir, 'Preferences'))) continue
profiles.push({ dir: entry.name, name: readProfileName(profileDir) })
}
} catch {
// 目录不可读则跳过该浏览器
continue
}
if (profiles.length > 0) {
sources.push({ id: source.id, name: source.name, userDataDir: root, profiles })
}
}
return sources
}
function copyFileSafe(from: string, to: string): void {
try {
copyFileSync(from, to)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') {
throw new Error('源浏览器文件被占用:请完全退出对应的浏览器(含后台进程)后重试。')
}
throw err
}
}
/** 拷贝 leveldb 目录(跳过 LOCK/LOG 运行时文件),目标已存在时先清空 */
function copyLevelDbDir(from: string, to: string): boolean {
if (!existsSync(from)) return false
const entries = readdirSync(from, { withFileTypes: true })
const files = entries.filter((e) => e.isFile() && e.name !== 'LOCK' && !e.name.startsWith('LOG'))
if (files.length === 0) return false
rmSync(to, { recursive: true, force: true })
mkdirSync(to, { recursive: true })
for (const file of files) {
copyFileSafe(join(from, file.name), join(to, file.name))
}
return true
}
interface SourceKeyInfo {
key: string | null
/** app-bound(V20)等不可移植场景的说明 */
note?: string
}
/**
* 读取源浏览器 Local State 里的 os_crypt.encrypted_key 并判断可移植性。
* Windows:前缀 DPAPI 可移植;V20 为 app-bound 加密(Chrome/Edge 127+),
* 其他进程无法解开,明确降级。macOS/Linux:密钥在系统钥匙串/KWallet,跳过。
*/
function readSourceKey(userDataDir: string): SourceKeyInfo {
if (process.platform !== 'win32') {
return { key: null, note: '当前平台的浏览器 Cookie 密钥绑定系统钥匙串,暂不支持迁移 Cookie(LocalStorage 登录态仍会导入)。' }
}
try {
const raw = JSON.parse(readFileSync(join(userDataDir, 'Local State'), 'utf8')) as {
os_crypt?: { encrypted_key?: string }
}
const encoded = raw.os_crypt?.encrypted_key
if (!encoded) return { key: null, note: '未找到源浏览器 Cookie 密钥,跳过 Cookie 迁移。' }
const bytes = Buffer.from(encoded, 'base64')
if (bytes.subarray(0, 3).toString('utf8') === 'V20') {
return { key: null, note: '该浏览器使用了应用绑定加密(app-bound encryption),无法第三方迁移 Cookie;已导入 Local Storage,首次访问各站点仍需登录一次。' }
}
if (bytes.subarray(0, 5).toString('utf8') !== 'DPAPI') {
return { key: null, note: '源浏览器 Cookie 密钥格式不受支持,跳过 Cookie 迁移。' }
}
return { key: encoded }
} catch {
return { key: null, note: '读取源浏览器 Local State 失败,跳过 Cookie 迁移。' }
}
}
/** 把源密钥移植进本应用根 Local State(首次会备份原文件) */
function transplantKey(sourceKey: string): void {
const appLocalState = join(app.getPath('userData'), 'Local State')
const backup = `${appLocalState}.aso-backup`
let doc: Record<string, unknown> = {}
if (existsSync(appLocalState)) {
try {
doc = JSON.parse(readFileSync(appLocalState, 'utf8')) as Record<string, unknown>
if (!existsSync(backup)) copyFileSync(appLocalState, backup)
} catch {
doc = {}
}
}
const osCrypt = (doc.os_crypt ?? {}) as Record<string, unknown>
osCrypt.encrypted_key = sourceKey
doc.os_crypt = osCrypt
writeFileSync(appLocalState, JSON.stringify(doc, null, 2), 'utf8')
}
/** 导入指定浏览器的 profile 配置到受管浏览器分区 */
export function importBrowserProfile(sourceId: string, profileDir: string): BrowserImportResult {
const browser = getBrowserController()
if (browser.hasTabs) {
throw new Error('请先关闭内置浏览器的所有标签页,再导入浏览器配置。')
}
const root = sourceRoot(sourceId)
if (!root || !existsSync(root)) throw new Error('未找到该浏览器的用户数据目录。')
// 只接受该根目录下的直接子目录名,拒绝任意路径注入
const dirName = profileDir.split(/[\\/]/).pop() ?? ''
const sourceProfile = join(root, dirName)
if (dirName !== profileDir || !existsSync(join(sourceProfile, 'Preferences'))) {
throw new Error('profile 目录不存在。')
}
const sourceName = SOURCE_ROOTS.find((s) => s.id === sourceId)?.name ?? sourceId
const target = partitionDir()
// 1) Local Storage(明文 leveldb,直接迁移)
let localStorage = false
try {
localStorage = copyLevelDbDir(join(sourceProfile, 'Local Storage', 'leveldb'), join(target, 'Local Storage', 'leveldb'))
} catch (err) {
throw new Error(err instanceof Error ? err.message : `Local Storage 迁移失败: ${String(err)}`)
}
// 2) Cookies + 密钥移植
let cookies = false
let cookieNote: string | undefined
const keyInfo = readSourceKey(root)
const cookieDb = existsSync(join(sourceProfile, 'Network', 'Cookies'))
? join(sourceProfile, 'Network', 'Cookies')
: existsSync(join(sourceProfile, 'Cookies'))
? join(sourceProfile, 'Cookies')
: null
if (keyInfo.key && cookieDb) {
try {
transplantKey(keyInfo.key)
mkdirSync(join(target, 'Network'), { recursive: true })
copyFileSafe(cookieDb, join(target, 'Network', 'Cookies'))
for (const suffix of ['-wal', '-shm']) {
if (existsSync(`${cookieDb}${suffix}`)) copyFileSafe(`${cookieDb}${suffix}`, join(target, 'Network', `Cookies${suffix}`))
}
cookies = true
} catch (err) {
cookieNote = err instanceof Error ? err.message : 'Cookie 迁移失败。'
}
} else {
cookieNote = keyInfo.note ?? '源浏览器没有可迁移的 Cookie 数据库。'
}
const parts: string[] = []
if (cookies) parts.push('Cookie(站点登录态)')
if (localStorage) parts.push('LocalStorage')
if (!cookies && !localStorage) {
return { ok: false, cookies, localStorage, cookieNote, message: `未在 ${sourceName}(${dirName})中找到可迁移的登录数据。` }
}
browser.setImportedInfo({
browser: sourceName,
profile: readProfileName(sourceProfile),
at: new Date().toISOString(),
cookies,
localStorage,
note: cookieNote
})
return {
ok: true,
cookies,
localStorage,
cookieNote,
message: `已从 ${sourceName} 导入 ${parts.join(' + ')}。${cookieNote ?? ''}${
cookies ? '若登录态未生效,重启应用后重试。' : ''
}`
}
}
+21 -6
View File
@@ -4,6 +4,10 @@ import { join } from 'node:path'
import { registerIpc } from './ipc' import { registerIpc } from './ipc'
import { getPluginHost } from './plugins/plugin-host' import { getPluginHost } from './plugins/plugin-host'
import { getBrowserController } from './browser/browser-controller' import { getBrowserController } from './browser/browser-controller'
import { getLogger, initLogger } from './logger'
initLogger()
const log = getLogger('main')
process.env.APP_ROOT = join(__dirname, '..') process.env.APP_ROOT = join(__dirname, '..')
@@ -22,6 +26,7 @@ export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
const APP_ICON = join(app.getAppPath(), 'build', 'icon.ico') const APP_ICON = join(app.getAppPath(), 'build', 'icon.ico')
const agentService = registerIpc() const agentService = registerIpc()
log.info('main init', { version: app.getVersion(), packaged: app.isPackaged, devServer: VITE_DEV_SERVER_URL ?? '-' })
/** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO, /** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO,
* 否则 overlay 的原生白色背景会盖住标题栏分隔线) */ * 否则 overlay 的原生白色背景会盖住标题栏分隔线) */
@@ -81,11 +86,18 @@ async function createWindow(): Promise<void> {
} }
}) })
// ready-to-show 在 Electron 中可能不止一次触发(实测:回合中途首次挂载受管浏览器
// WebContentsView 后会再次发出)。会话初始化只做一次——但失败后放开门闩,
// 让后续的重复触发承担重试职责(该事件至少会再来一次)。
let sessionInitialized = false
win.on('ready-to-show', () => { win.on('ready-to-show', () => {
win.show() win.show()
if (sessionInitialized) return
sessionInitialized = true
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现 // pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
agentService.ensureReady().catch((err) => { agentService.ensureReady().catch((err: unknown) => {
console.error('[main] pi session init failed:', err) sessionInitialized = false
log.error('pi session init failed', { err })
}) })
}) })
@@ -104,15 +116,17 @@ async function createWindow(): Promise<void> {
return { action: 'deny' } return { action: 'deny' }
}) })
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题 // 渲染层日志(含插件 renderer 模块 / iframe 页的 console)统一归档到日志文件
win.webContents.on('console-message', (details) => { win.webContents.on('console-message', (details) => {
console.log(`[renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`) const level = details.level === 'error' ? 'error' : details.level === 'warning' ? 'warn' : 'debug'
if (level === 'debug' && !VITE_DEV_SERVER_URL) return
log[level]('renderer console', { message: details.message, source: `${details.sourceId}:${details.lineNumber}` })
}) })
win.webContents.on('did-fail-load', (_e, code, desc, url) => { win.webContents.on('did-fail-load', (_e, code, desc, url) => {
console.log(`[renderer:load-fail] ${code} ${desc} ${url}`) log.error('renderer load failed', { code, desc, url })
}) })
win.webContents.on('render-process-gone', (_e, details) => { win.webContents.on('render-process-gone', (_e, details) => {
console.log(`[renderer:gone] ${details.reason}`) log.error('renderer gone', { reason: details.reason })
}) })
if (process.env.ASO_DEVTOOLS === '1') { if (process.env.ASO_DEVTOOLS === '1') {
win.webContents.openDevTools({ mode: 'detach' }) win.webContents.openDevTools({ mode: 'detach' })
@@ -143,6 +157,7 @@ if (!app.requestSingleInstanceLock()) {
app.setAppUserModelId('com.inkreach.agent') app.setAppUserModelId('com.inkreach.agent')
} }
Menu.setApplicationMenu(null) Menu.setApplicationMenu(null)
log.info('app ready')
// 插件宿主:扫描/装载 + 协议映射 + 插件启停后重建 agent 运行时 // 插件宿主:扫描/装载 + 协议映射 + 插件启停后重建 agent 运行时
const pluginHost = getPluginHost() const pluginHost = getPluginHost()
+158 -68
View File
@@ -1,10 +1,29 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { mkdirSync } from 'node:fs' import { mkdirSync, readFileSync } from 'node:fs'
import { userInfo } from 'node:os' import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service' import { AgentService } from './pi/agent-service'
import { getPluginHost } from './plugins/plugin-host' import { getPluginHost } from './plugins/plugin-host'
import { getBrowserController } from './browser/browser-controller' import { getBrowserController } from './browser/browser-controller'
import { detectBrowserSources, importBrowserProfile } from './browser/browser-profile' import { getLogger } from './logger'
const log = getLogger('ipc')
/* eslint-disable @typescript-eslint/no-explicit-any */
/** ipcMain.handle 追踪包装:成功 debug(含耗时)、失败 error(含通道/耗时)——Agent 自诊断的数据源之一 */
function handle(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any): void {
ipcMain.handle(channel, async (event, ...args: any[]) => {
const started = Date.now()
try {
const result = await listener(event, ...args)
log.debug('ipc ok', { channel, ms: Date.now() - started })
return result
} catch (err) {
log.error('ipc failed', { channel, ms: Date.now() - started, err })
throw err
}
})
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/** /**
* 注册 IPC 通道并把 agent 事件转发到所有渲染窗口。 * 注册 IPC 通道并把 agent 事件转发到所有渲染窗口。
@@ -21,7 +40,7 @@ export function registerIpc(): AgentService {
} }
}) })
ipcMain.handle('agent:send', (_event, text: unknown, images: unknown) => { handle('agent:send', (_event, text: unknown, images: unknown) => {
if (typeof text !== 'string' || !text.trim()) { if (typeof text !== 'string' || !text.trim()) {
throw new Error('empty prompt') throw new Error('empty prompt')
} }
@@ -37,49 +56,49 @@ export function registerIpc(): AgentService {
return service.send(text, sendImages) return service.send(text, sendImages)
}) })
ipcMain.handle('agent:steer', (_event, text: unknown) => { handle('agent:steer', (_event, text: unknown) => {
if (typeof text !== 'string' || !text.trim()) { if (typeof text !== 'string' || !text.trim()) {
throw new Error('empty steer text') throw new Error('empty steer text')
} }
return service.steer(text) return service.steer(text)
}) })
ipcMain.handle('agent:stop', () => service.stop()) handle('agent:stop', () => service.stop())
ipcMain.handle('agent:state', () => handle('agent:state', () =>
service.getState().catch((err: unknown) => { service.getState().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
) )
// ---- 会话管理 ---- // ---- 会话管理 ----
ipcMain.handle('sessions:list', () => handle('sessions:list', () =>
service.listSessions().catch((err: unknown) => { service.listSessions().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
) )
ipcMain.handle('sessions:new', () => handle('sessions:new', () =>
service.newSession().catch((err: unknown) => { service.newSession().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
) )
ipcMain.handle('sessions:open', (_event, path: unknown) => { handle('sessions:open', (_event, path: unknown) => {
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path') if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path')
return service.openSession(path).catch((err: unknown) => { return service.openSession(path).catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
}) })
ipcMain.handle('sessions:rename', (_event, name: unknown) => { handle('sessions:rename', (_event, name: unknown) => {
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid session name') if (typeof name !== 'string' || !name.trim()) throw new Error('invalid session name')
return service.renameSession(name.trim()).catch((err: unknown) => { return service.renameSession(name.trim()).catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
}) })
ipcMain.handle('sessions:delete', (_event, path: unknown) => { handle('sessions:delete', (_event, path: unknown) => {
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path') if (typeof path !== 'string' || !path.trim()) throw new Error('invalid session path')
return service.deleteSession(path).catch((err: unknown) => { return service.deleteSession(path).catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
@@ -92,12 +111,12 @@ export function registerIpc(): AgentService {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
ipcMain.handle('models:catalog', () => pass(() => service.getModelCatalog())) handle('models:catalog', () => pass(() => service.getModelCatalog()))
// ---- 自定义 API 提供商(models.json)---- // ---- 自定义 API 提供商(models.json)----
ipcMain.handle('models:custom:list', () => service.listCustomProviders()) handle('models:custom:list', () => service.listCustomProviders())
ipcMain.handle('models:custom:save', (_event, config: unknown) => { handle('models:custom:save', (_event, config: unknown) => {
if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider') if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider')
const c = config as Record<string, unknown> const c = config as Record<string, unknown>
if ( if (
@@ -120,51 +139,51 @@ export function registerIpc(): AgentService {
return pass(() => service.saveCustomProvider(normalized)) return pass(() => service.saveCustomProvider(normalized))
}) })
ipcMain.handle('models:custom:delete', (_event, id: unknown) => { handle('models:custom:delete', (_event, id: unknown) => {
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id') if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id')
return pass(() => service.deleteCustomProvider(id)) return pass(() => service.deleteCustomProvider(id))
}) })
ipcMain.handle('models:set', (_event, providerId: unknown, modelId: unknown) => { handle('models:set', (_event, providerId: unknown, modelId: unknown) => {
if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model') if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model')
return pass(() => service.setModel(providerId, modelId)) return pass(() => service.setModel(providerId, modelId))
}) })
ipcMain.handle('auth:setKey', (_event, providerId: unknown, apiKey: unknown) => { handle('auth:setKey', (_event, providerId: unknown, apiKey: unknown) => {
if (typeof providerId !== 'string' || typeof apiKey !== 'string' || !apiKey.trim()) { if (typeof providerId !== 'string' || typeof apiKey !== 'string' || !apiKey.trim()) {
throw new Error('invalid api key') throw new Error('invalid api key')
} }
return pass(() => service.setApiKey(providerId, apiKey.trim())) return pass(() => service.setApiKey(providerId, apiKey.trim()))
}) })
ipcMain.handle('auth:logout', (_event, providerId: unknown) => { handle('auth:logout', (_event, providerId: unknown) => {
if (typeof providerId !== 'string') throw new Error('invalid provider') if (typeof providerId !== 'string') throw new Error('invalid provider')
return pass(() => service.logoutProvider(providerId)) return pass(() => service.logoutProvider(providerId))
}) })
// ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ---- // ---- 思考等级 / 压缩 / 分支摘要(pi 原生能力) ----
ipcMain.handle('thinking:set', (_event, level: unknown) => { handle('thinking:set', (_event, level: unknown) => {
if (typeof level !== 'string') throw new Error('invalid thinking level') if (typeof level !== 'string') throw new Error('invalid thinking level')
return pass(() => service.setThinkingLevel(level as never)) return pass(() => service.setThinkingLevel(level as never))
}) })
ipcMain.handle('session:compact', (_event, instructions: unknown) => { handle('session:compact', (_event, instructions: unknown) => {
if (instructions !== undefined && instructions !== null && typeof instructions !== 'string') { if (instructions !== undefined && instructions !== null && typeof instructions !== 'string') {
throw new Error('invalid compaction instructions') throw new Error('invalid compaction instructions')
} }
return pass(() => service.compactSession(instructions || undefined)) return pass(() => service.compactSession(instructions || undefined))
}) })
ipcMain.handle('session:compact:abort', () => pass(() => service.abortCompaction())) handle('session:compact:abort', () => pass(() => service.abortCompaction()))
ipcMain.handle('session:autoCompact', (_event, enabled: unknown) => { handle('session:autoCompact', (_event, enabled: unknown) => {
if (typeof enabled !== 'boolean') throw new Error('invalid flag') if (typeof enabled !== 'boolean') throw new Error('invalid flag')
return pass(() => service.setAutoCompactionEnabled(enabled)) return pass(() => service.setAutoCompactionEnabled(enabled))
}) })
ipcMain.handle('session:forkPoints', () => pass(() => service.listForkPoints())) handle('session:forkPoints', () => pass(() => service.listForkPoints()))
ipcMain.handle('session:navigate', (_event, entryId: unknown, options: unknown) => { handle('session:navigate', (_event, entryId: unknown, options: unknown) => {
if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id') if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id')
const o = (options ?? {}) as { summarize?: unknown; customInstructions?: unknown } const o = (options ?? {}) as { summarize?: unknown; customInstructions?: unknown }
if (o.summarize !== undefined && typeof o.summarize !== 'boolean') throw new Error('invalid navigate options') if (o.summarize !== undefined && typeof o.summarize !== 'boolean') throw new Error('invalid navigate options')
@@ -180,46 +199,54 @@ export function registerIpc(): AgentService {
}) })
// 会话末尾分叉为新会话(pi fork,原会话保留) // 会话末尾分叉为新会话(pi fork,原会话保留)
ipcMain.handle('session:fork', () => pass(() => service.forkSession())) handle('session:fork', () => pass(() => service.forkSession()))
// 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止) // 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止)
ipcMain.handle('session:forkAt', (_event, entryId: unknown, position: unknown) => { handle('session:forkAt', (_event, entryId: unknown, position: unknown) => {
if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id') if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id')
if (position !== 'at' && position !== 'before') throw new Error('invalid fork position') if (position !== 'at' && position !== 'before') throw new Error('invalid fork position')
return pass(() => service.forkSessionAt(entryId, position)) return pass(() => service.forkSessionAt(entryId, position))
}) })
// ---- 应用设置 ---- // ---- 应用设置 ----
ipcMain.handle('settings:get', () => service.getSettings()) handle('settings:get', () => service.getSettings())
// 当前生效的系统提示词(pi 原生 + 技能注入,只读展示) // 当前生效的系统提示词(pi 原生 + 技能注入,只读展示)
ipcMain.handle('settings:getSystemPrompt', () => service.getSystemPrompt()) handle('settings:getSystemPrompt', () => service.getSystemPrompt())
ipcMain.handle('settings:setTheme', (_event, id: unknown) => { handle('settings:setTheme', (_event, id: unknown) => {
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid theme id') if (typeof id !== 'string' || !id.trim()) throw new Error('invalid theme id')
service.setThemeColor(id.trim()) service.setThemeColor(id.trim())
}) })
ipcMain.handle('settings:setThemeMode', (_event, mode: unknown) => { handle('settings:setThemeMode', (_event, mode: unknown) => {
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') throw new Error('invalid theme mode') if (mode !== 'light' && mode !== 'dark' && mode !== 'system') throw new Error('invalid theme mode')
service.setThemeMode(mode) service.setThemeMode(mode)
}) })
// 启用/禁用单个技能或命令(重建运行时使发现阶段过滤生效)
handle('settings:setResourceEnabled', (_event, kind: unknown, name: unknown, enabled: unknown) => {
if (kind !== 'skill' && kind !== 'command') throw new Error('invalid resource kind')
if (typeof name !== 'string' || !name.trim() || name.length > 200) throw new Error('invalid resource name')
if (typeof enabled !== 'boolean') throw new Error('invalid enabled flag')
return service.setResourceEnabled(kind, name, enabled)
})
// ---- 工作区 ---- // ---- 工作区 ----
ipcMain.handle('workspaces:list', () => handle('workspaces:list', () =>
service.listWorkspaces().catch((err: unknown) => { service.listWorkspaces().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
) )
ipcMain.handle('workspaces:create', (_event, name: unknown) => { handle('workspaces:create', (_event, name: unknown) => {
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name') if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name')
return service.createWorkspace(name).catch((err: unknown) => { return service.createWorkspace(name).catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
}) })
ipcMain.handle('workspaces:open', (_event, path: unknown) => { handle('workspaces:open', (_event, path: unknown) => {
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path') if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path')
return service.openWorkspace(path).catch((err: unknown) => { return service.openWorkspace(path).catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
@@ -227,7 +254,7 @@ export function registerIpc(): AgentService {
}) })
// 调起系统目录选择器;取消返回 null // 调起系统目录选择器;取消返回 null
ipcMain.handle('workspaces:browse', async () => { handle('workspaces:browse', async () => {
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
const result = await dialog.showOpenDialog(win, { const result = await dialog.showOpenDialog(win, {
title: '选择工作区目录', title: '选择工作区目录',
@@ -239,14 +266,14 @@ export function registerIpc(): AgentService {
}) })
// ---- 工具审批 ---- // ---- 工具审批 ----
ipcMain.handle('approval:resolve', (_event, id: unknown, approved: unknown) => { handle('approval:resolve', (_event, id: unknown, approved: unknown) => {
if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval') if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval')
// settle 内部经 onSettle 订阅发出 approval_settled,此处无需重复广播
service.approvals.settle(id, approved) service.approvals.settle(id, approved)
service.broadcastEvent({ type: 'approval_settled', id, approved, timedOut: false })
return Promise.resolve() return Promise.resolve()
}) })
ipcMain.handle('app:versions', () => ({ handle('app:versions', () => ({
app: process.env.npm_package_version ?? '0.0.0', app: process.env.npm_package_version ?? '0.0.0',
electron: process.versions.electron, electron: process.versions.electron,
node: process.versions.node, node: process.versions.node,
@@ -255,38 +282,38 @@ export function registerIpc(): AgentService {
})) }))
// ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)---- // ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
ipcMain.handle('chat:resources', () => handle('chat:resources', () =>
service.listChatResources().catch((err: unknown) => { service.listChatResources().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
) )
ipcMain.handle('queue:set', (_event, steering: unknown, followUp: unknown) => { handle('queue:set', (_event, steering: unknown, followUp: unknown) => {
const norm = (value: unknown): string[] => const norm = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
return service.setQueue(norm(steering), norm(followUp)) return service.setQueue(norm(steering), norm(followUp))
}) })
ipcMain.handle('permissions:set', (_event, mode: unknown) => { handle('permissions:set', (_event, mode: unknown) => {
if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode') if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode')
service.setPermissionMode(mode) service.setPermissionMode(mode)
}) })
// ---- 设置页:技能 / 命令详情 ---- // ---- 设置页:技能 / 命令详情 ----
ipcMain.handle('resources:details', () => handle('resources:details', () =>
service.listResourceDetails().catch((err: unknown) => { service.listResourceDetails().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
) )
// 在系统文件管理器中打开(技能/命令来源文件所在目录) // 在系统文件管理器中打开(技能/命令来源文件所在目录)
ipcMain.handle('shell:openPath', (_event, target: unknown) => { handle('shell:openPath', (_event, target: unknown) => {
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path') if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
return shell.openPath(target) return shell.openPath(target)
}) })
// 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立) // 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立)
ipcMain.handle('shell:openDirectory', (_event, target: unknown) => { handle('shell:openDirectory', (_event, target: unknown) => {
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path') if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
try { try {
mkdirSync(target, { recursive: true }) mkdirSync(target, { recursive: true })
@@ -299,13 +326,13 @@ export function registerIpc(): AgentService {
}) })
}) })
ipcMain.handle('files:list', () => handle('files:list', () =>
service.listWorkspaceFiles().catch((err: unknown) => { service.listWorkspaceFiles().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err)) throw new Error(err instanceof Error ? err.message : String(err))
}) })
) )
ipcMain.handle('attachments:pick', async (event) => { handle('attachments:pick', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0] const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0]
return service.pickAttachments(win ?? undefined) return service.pickAttachments(win ?? undefined)
}) })
@@ -329,30 +356,30 @@ export function registerIpc(): AgentService {
} }
} }
ipcMain.handle('browser:getState', () => browserPass(() => getBrowserController().getState())) handle('browser:getState', () => browserPass(() => getBrowserController().getState()))
ipcMain.handle('browser:navigate', (_e, input: unknown) => handle('browser:navigate', (_e, input: unknown) =>
browserPass(() => getBrowserController().navigateDisplay(typeof input === 'string' ? input : '')) browserPass(() => getBrowserController().navigateDisplay(typeof input === 'string' ? input : ''))
) )
ipcMain.handle('browser:goBack', () => browserPass(() => getBrowserController().goBack())) handle('browser:goBack', () => browserPass(() => getBrowserController().goBack()))
ipcMain.handle('browser:goForward', () => browserPass(() => getBrowserController().goForward())) handle('browser:goForward', () => browserPass(() => getBrowserController().goForward()))
ipcMain.handle('browser:reload', () => browserPass(() => getBrowserController().reload())) handle('browser:reload', () => browserPass(() => getBrowserController().reload()))
ipcMain.handle('browser:newTab', (_e, url: unknown) => handle('browser:newTab', (_e, url: unknown) =>
browserPass(() => getBrowserController().newTab(typeof url === 'string' && url ? url : undefined, false)) browserPass(() => getBrowserController().newTab(typeof url === 'string' && url ? url : undefined, false))
) )
ipcMain.handle('browser:selectTab', (_e, tabId: unknown) => handle('browser:selectTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().selectTab(typeof tabId === 'string' ? tabId : '')) browserPass(() => getBrowserController().selectTab(typeof tabId === 'string' ? tabId : ''))
) )
ipcMain.handle('browser:closeTab', (_e, tabId: unknown) => handle('browser:closeTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined)) browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined))
) )
ipcMain.handle('browser:close', () => browserPass(() => getBrowserController().close())) handle('browser:close', () => browserPass(() => getBrowserController().close()))
// 渲染层冻结帧:菜单弹出需暂时隐藏原生视图前,截当前页面画面做占位 // 渲染层冻结帧:菜单弹出需暂时隐藏原生视图前,截当前页面画面做占位
ipcMain.handle('browser:captureTab', (_e, tabId: unknown) => handle('browser:captureTab', (_e, tabId: unknown) =>
browserPass(() => getBrowserController().captureTabDataUrl(typeof tabId === 'string' && tabId ? tabId : undefined)) browserPass(() => getBrowserController().captureTabDataUrl(typeof tabId === 'string' && tabId ? tabId : undefined))
) )
ipcMain.handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk())) handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk()))
// 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局) // 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局)
ipcMain.handle('browser:layout', (_e, layout: unknown) => { handle('browser:layout', (_e, layout: unknown) => {
const l = layout as { tabId?: unknown; visible?: unknown; revision?: unknown; bounds?: unknown } const l = layout as { tabId?: unknown; visible?: unknown; revision?: unknown; bounds?: unknown }
const { tabId, visible, revision } = l const { tabId, visible, revision } = l
const bounds = l.bounds as { x?: unknown; y?: unknown; width?: unknown; height?: unknown } | undefined const bounds = l.bounds as { x?: unknown; y?: unknown; width?: unknown; height?: unknown } | undefined
@@ -374,11 +401,15 @@ export function registerIpc(): AgentService {
return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } })) return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } }))
}) })
// ---- 本地浏览器配置导入 ---- // ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ----
ipcMain.handle('browser:detectSources', () => browserPass(() => detectBrowserSources())) handle('browser:getProxy', () => getBrowserController().getProxySettings())
ipcMain.handle('browser:importProfile', (_e, sourceId: unknown, profileDir: unknown) => { handle('browser:setProxy', (_e, input: unknown) => {
if (typeof sourceId !== 'string' || typeof profileDir !== 'string') throw new Error('invalid import source') if (!input || typeof input !== 'object') throw new Error('invalid proxy settings')
return browserPass(() => importBrowserProfile(sourceId, profileDir)) const { mode, rules, bypass } = input as Record<string, unknown>
if (mode !== 'system' && mode !== 'direct' && mode !== 'fixed') throw new Error('invalid proxy mode')
if (rules !== undefined && typeof rules !== 'string') throw new Error('invalid proxy rules')
if (bypass !== undefined && typeof bypass !== 'string') throw new Error('invalid proxy bypass')
return getBrowserController().setProxySettings({ mode, rules, bypass })
}) })
// ---- 插件系统 ---- // ---- 插件系统 ----
@@ -387,19 +418,78 @@ export function registerIpc(): AgentService {
getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime()) getPluginHost().setRuntimeRebuilder(() => service.rebuildRuntime())
}) })
ipcMain.handle('plugins:list', () => getPluginHost().list()) handle('plugins:list', () => getPluginHost().list())
ipcMain.handle('plugins:uiState', () => getPluginHost().uiState()) handle('plugins:uiState', () => getPluginHost().uiState())
ipcMain.handle('plugins:setEnabled', (_e, id: unknown, enabled: unknown) => { // 外置插件来源目录(设置页「打开目录」)
handle('plugins:dirs', () => getPluginHost().pluginDirs())
// 外置插件渲染层模块源码(渲染层以 Blob URL import,绕开 dev http 源对 plugin:// 的 CORS 限制)
handle('plugins:readRenderer', (_e, id: unknown) => {
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
return getPluginHost().readRendererSource(id)
})
// ---- 插件设置表单(声明式 schema;值持久化与动作回推在 plugin-host) ----
handle('plugins:settingsSchemas', () => getPluginHost().settingsSchemasList())
handle('plugins:settingsValues', () => getPluginHost().settingsValuesList())
handle('plugins:setSettingValue', (_e, id: unknown, key: unknown, value: unknown) => {
if (typeof id !== 'string' || typeof key !== 'string' || !key) throw new Error('invalid setting value')
getPluginHost().setSettingValue(id, key, value)
})
handle('plugins:setSettingsNav', (_e, id: unknown, on: unknown) => {
if (typeof id !== 'string' || typeof on !== 'boolean') throw new Error('invalid settings nav toggle')
return getPluginHost().setSettingsNav(id, on)
})
handle('plugins:settingsAction', (_e, id: unknown, key: unknown) => {
if (typeof id !== 'string' || typeof key !== 'string') throw new Error('invalid settings action')
getPluginHost().settingsAction(id, key)
})
// path/image 控件的系统选择框
handle('plugins:pickPath', async (event, mode: unknown) => {
if (mode !== 'file' && mode !== 'directory' && mode !== 'image') throw new Error('invalid path mode')
const win = BrowserWindow.fromWebContents(event.sender)
const filters =
mode === 'image'
? [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico', 'avif'] }]
: undefined
const options = { filters }
const result =
mode === 'directory'
? await (win ? dialog.showOpenDialog(win, { ...options, properties: ['openDirectory'] }) : dialog.showOpenDialog({ ...options, properties: ['openDirectory'] }))
: await (win ? dialog.showOpenDialog(win, { ...options, properties: ['openFile'] }) : dialog.showOpenDialog({ ...options, properties: ['openFile'] }))
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0]
})
// image 控件缩略图:渲染层(dev 是 http 源)不能直接 <img src="file://">,回传 data URL
handle('plugins:readImage', (_e, path: unknown) => {
if (typeof path !== 'string' || !path || !/\.(png|jpe?g|gif|webp|bmp|svg|ico|avif)$/i.test(path)) return null
try {
const buf = readFileSync(path)
if (buf.length > 5 * 1024 * 1024) return null
const ext = path.split('.').pop()!.toLowerCase()
return `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`
} catch {
return null
}
})
handle('plugins:setEnabled', (_e, id: unknown, enabled: unknown) => {
if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle') if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle')
return getPluginHost().setEnabled(id, enabled) return getPluginHost().setEnabled(id, enabled)
}) })
ipcMain.handle('plugins:reload', () => getPluginHost().reload()) // 打开插件私有数据目录(资源管理器中显示;不存在则先建)
ipcMain.handle('plugins:openView', (_e, view: unknown, params: unknown) => { handle('plugins:openDataDir', (_e, id: unknown) => {
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
return getPluginHost().openPluginDataDir(id)
})
// 清除插件全部存储数据(设置页二次确认后调用;连接关闭 + 删数据目录)
handle('plugins:clearData', (_e, id: unknown) => {
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('invalid plugin id')
return getPluginHost().clearPluginData(id)
})
handle('plugins:reload', () => getPluginHost().reload())
handle('plugins:openView', (_e, view: unknown, params: unknown) => {
if (typeof view !== 'string' || !view) throw new Error('invalid view') if (typeof view !== 'string' || !view) throw new Error('invalid view')
getPluginHost().openView(view, params) getPluginHost().openView(view, params)
}) })
// 插件页(iframe)经渲染层桥转发来的状态胶囊写请求;pluginId 键控防越权 // 插件页(iframe)经渲染层桥转发来的状态胶囊写请求;pluginId 键控防越权
ipcMain.handle('plugins:setStatus', (_e, pluginId: unknown, itemId: unknown, item: unknown) => { handle('plugins:setStatus', (_e, pluginId: unknown, itemId: unknown, item: unknown) => {
if (typeof pluginId !== 'string' || typeof itemId !== 'string') throw new Error('invalid status item') if (typeof pluginId !== 'string' || typeof itemId !== 'string') throw new Error('invalid status item')
getPluginHost().setStatusItem(pluginId, itemId, (item ?? null) as never) getPluginHost().setStatusItem(pluginId, itemId, (item ?? null) as never)
return Promise.resolve() return Promise.resolve()
+142
View File
@@ -0,0 +1,142 @@
/**
* 统一日志基座(主进程):
* - 结构化行:`<ISO时间> <LEVEL> [<scope>] <message> k=v ...`,文件按天滚动
* (userData/logs/main-YYYY-MM-DD.log,保留 14 天)
* - 级别:dev 默认 debug、打包默认 info;ASO_LOG_LEVEL=debug|info|warn|error 可覆盖
* - 子 logger:getLogger(scope) —— 各模块用固定 scope(plugin-host / agent-service / ipc / renderer /
* browser),插件日志经 plugin-host 注入 scope=plugin 且带 pluginId 字段
* - 全局兜底:uncaughtException / unhandledRejection / will-quit 落盘
*
* Agent 自诊断约定:日志文件路径固定,pi 侧可直接读文件排查(见 docs/plugins/06-self-diagnosis.md)。
*/
import { app } from 'electron'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
const RETAIN_DAYS = 14
const MAX_LINE = 8 * 1024
let level: LogLevel = 'info'
let logDir: string | null = null
/** 当天文件名(含日期)缓存;跨天自动切换 */
let fileDay = ''
let fileReady = false
/** 文件不可用时保留最近日志(进程内仍可读) */
const memoryTail: string[] = []
function resolveLevel(): LogLevel {
const env = process.env.ASO_LOG_LEVEL
if (env === 'debug' || env === 'info' || env === 'warn' || env === 'error') return env
return app.isPackaged ? 'info' : 'debug'
}
function ensureFile(now: Date): string | null {
const day = now.toISOString().slice(0, 10)
if (fileReady && fileDay === day && logDir) return join(logDir, `main-${day}.log`)
try {
if (!logDir) logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
pruneOldLogs(logDir, day)
fileDay = day
fileReady = true
return join(logDir, `main-${day}.log`)
} catch {
return null
}
}
function pruneOldLogs(dir: string, today: string): void {
try {
for (const name of readdirSync(dir)) {
const m = /^main-(\d{4}-\d{2}-\d{2})\.log$/.exec(name)
if (!m) continue
const ageDays = (Date.now() - statSync(join(dir, name)).mtimeMs) / 86_400_000
if (m[1] !== today && (ageDays > RETAIN_DAYS || m[1] > today)) unlinkSync(join(dir, name))
}
} catch {
// 清理失败不影响写日志
}
}
function fmtValue(v: unknown): string {
if (v === undefined) return 'undefined'
if (v === null) return 'null'
if (v instanceof Error) return v.stack ? v.stack.replace(/\s+/g, ' ').slice(0, 600) : v.message
if (typeof v === 'object') {
try {
return JSON.stringify(v).slice(0, 400)
} catch {
return '[object]'
}
}
return String(v).slice(0, 400)
}
function write(level_: LogLevel, scope: string, message: string, kv?: Record<string, unknown>): void {
if (LEVEL_ORDER[level_] < LEVEL_ORDER[level]) return
const now = new Date()
const parts = [
now.toISOString(),
level_.toUpperCase().padEnd(5),
`[${scope}]`,
message.slice(0, 500)
]
if (kv) {
for (const [k, v] of Object.entries(kv)) parts.push(`${k}=${fmtValue(v)}`)
}
const line = parts.join(' ').slice(0, MAX_LINE)
// 控制台镜像(dev 终端可读)
// eslint-disable-next-line no-console
;(level_ === 'error' ? console.error : level_ === 'warn' ? console.warn : console.log)(line)
memoryTail.push(line)
if (memoryTail.length > 500) memoryTail.shift()
const file = ensureFile(now)
if (file) {
try {
appendFileSync(file, line + '\n', 'utf8')
} catch {
fileReady = false
}
}
}
export interface Logger {
debug(message: string, kv?: Record<string, unknown>): void
info(message: string, kv?: Record<string, unknown>): void
warn(message: string, kv?: Record<string, unknown>): void
error(message: string, kv?: Record<string, unknown>): void
}
export function getLogger(scope: string): Logger {
return {
debug: (message, kv) => write('debug', scope, message, kv),
info: (message, kv) => write('info', scope, message, kv),
warn: (message, kv) => write('warn', scope, message, kv),
error: (message, kv) => write('error', scope, message, kv)
}
}
/** 最近日志(内存尾部;诊断 IPC 预留) */
export function recentLines(count = 100): string[] {
return memoryTail.slice(-count)
}
export function initLogger(): void {
level = resolveLevel()
const log = getLogger('logger')
process.on('uncaughtException', (err) => {
log.error('uncaughtException', { err })
})
process.on('unhandledRejection', (reason) => {
log.error('unhandledRejection', { err: reason })
})
app.on('will-quit', () => {
log.info('app quit')
})
log.info('logger init', { level, userData: app.getPath('userData') })
}
+257 -98
View File
@@ -1,3 +1,6 @@
import { getLogger } from '../logger'
const log = getLogger('agent-service')
import { app, dialog, shell } from 'electron' import { app, dialog, shell } from 'electron'
import { nativeTheme } from 'electron' import { nativeTheme } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
@@ -9,15 +12,18 @@ import {
createAgentSessionFromServices, createAgentSessionFromServices,
estimateTokens, estimateTokens,
getAgentDir, getAgentDir,
getPowerShellConfig,
SessionManager, SessionManager,
type AgentSession, type AgentSession,
type AgentSessionEvent, type AgentSessionEvent,
type AgentSessionRuntime, type AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeFactory,
type SessionInfo type PromptTemplate,
type SessionInfo,
type Skill
} from '@earendil-works/pi-coding-agent' } from '@earendil-works/pi-coding-agent'
import { ApprovalService, createApprovalExtension } from './approval' import { ApprovalService, createApprovalExtension } from './approval'
import { getPluginHost } from '../plugins/plugin-host' import { EXTERNAL_PLUGINS_ENABLED, getPluginHost } from '../plugins/plugin-host'
import { browserToolNames } from '../tools/browser-tools' import { browserToolNames } from '../tools/browser-tools'
import type { import type {
AgentUiEvent, AgentUiEvent,
@@ -53,7 +59,38 @@ const DELTA_FLUSH_MS = 40
const CUSTOM_TOOL_NAMES = new Set<string>(browserToolNames) const CUSTOM_TOOL_NAMES = new Set<string>(browserToolNames)
/** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */ /** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */
const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = new Set(['bash', 'write', 'edit']) const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = new Set(['bash', 'powershell', 'write', 'edit'])
/** 资源禁用清单(disabledSkills/disabledCommands)的白名单式解析:仅保留非空字符串并去重 */
function parseNameList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return [...new Set(value.filter((n): n is string => typeof n === 'string' && n.trim().length > 0).map((n) => n.trim()))]
}
/** Windows 优先 PowerShell:探测结果进程内缓存(getPowerShellConfig 会 spawnSync where 探测,
* 且平台与安装情况不会在应用运行中变化)。探测失败保持 bash(Git Bash)不动。 */
let powershellProbe: boolean | null = null
/** win32 且存在 pwsh/powershell 可执行文件时,把会话的默认 bash 工具换成 powershell,
* 模型的 shell 调用走原生 PowerShell 而非 Git Bash。幂等:无 bash 或已换过则原样返回。
* 注意不能用 options.tools 传入(那会变成工具白名单,把插件注册的工具全部过滤掉),
* 也不能只传四个基础名调 setActiveToolsByName(那会把已激活的扩展工具一并挤掉),
* 所以按当前激活名单做替换。 */
function preferPowerShell(session: AgentSession): void {
if (process.platform !== 'win32') return
if (powershellProbe === null) {
try {
getPowerShellConfig()
powershellProbe = true
} catch {
powershellProbe = false
}
}
if (!powershellProbe) return
const names = session.getActiveToolNames()
if (!names.includes('bash') || names.includes('powershell')) return
session.setActiveToolsByName(names.map((name) => (name === 'bash' ? 'powershell' : name)))
}
/** /**
* pi 会话运行时的主进程封装: * pi 会话运行时的主进程封装:
@@ -77,6 +114,10 @@ export class AgentService {
private permissionMode: PermissionMode = 'confirm' private permissionMode: PermissionMode = 'confirm'
/** 技能清单 token 估算缓存(随工作区失效) */ /** 技能清单 token 估算缓存(随工作区失效) */
private skillsEstimate: { cwd: string; value: number } | null = null private skillsEstimate: { cwd: string; value: number } | null = null
/** pi 全量发现的技能/命令快照(skillsOverride/promptsOverride 在每次 loader reload 时写入)。
* 被禁用的资源在 loader 里已过滤掉,设置页靠这份快照展示"已禁用"状态。 */
private resourceSnapshotSkills: Skill[] = []
private resourceSnapshotPrompts: PromptTemplate[] = []
readonly approvals = new ApprovalService() readonly approvals = new ApprovalService()
constructor() { constructor() {
@@ -115,7 +156,9 @@ export class AgentService {
permissionMode: permissionMode:
raw.permissionMode === 'full' ? 'full' : 'confirm', raw.permissionMode === 'full' ? 'full' : 'confirm',
themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined, themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined,
themeMode: raw.themeMode === 'light' || raw.themeMode === 'dark' ? raw.themeMode : 'system' themeMode: raw.themeMode === 'light' || raw.themeMode === 'dark' ? raw.themeMode : 'system',
disabledSkills: parseNameList(raw.disabledSkills),
disabledCommands: parseNameList(raw.disabledCommands)
} }
} catch { } catch {
this.settings = { permissionMode: this.permissionMode, themeMode: 'system' } this.settings = { permissionMode: this.permissionMode, themeMode: 'system' }
@@ -130,7 +173,7 @@ export class AgentService {
try { try {
mkdirSync(this.defaultWorkspaceDir, { recursive: true }) mkdirSync(this.defaultWorkspaceDir, { recursive: true })
} catch (err) { } catch (err) {
console.error('[agent-service] init workspaces failed:', err) log.error('init workspaces failed:', { err: err })
} }
const saved = this.settings.workspace const saved = this.settings.workspace
this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
@@ -154,7 +197,7 @@ export class AgentService {
seen.add(path.toLowerCase()) seen.add(path.toLowerCase())
} }
} catch (err) { } catch (err) {
console.error('[agent-service] list workspaces failed:', err) log.error('list workspaces failed:', { err: err })
} }
// 目录选择器添加的根目录之外的工作区 // 目录选择器添加的根目录之外的工作区
for (const p of this.settings.customWorkspaces ?? []) { for (const p of this.settings.customWorkspaces ?? []) {
@@ -199,14 +242,15 @@ export class AgentService {
this.settings.workspace = resolved this.settings.workspace = resolved
this.saveSettings() this.saveSettings()
this.workspaceDir = resolved this.workspaceDir = resolved
await this.rebuildRuntime() // 工作区切换:新 cwd 的最近会话才是目标(不做原会话恢复)
await this.rebuildRuntime({ restoreSession: false })
} }
private saveSettings(): void { private saveSettings(): void {
try { try {
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8') writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2), 'utf8')
} catch (err) { } catch (err) {
console.error('[agent-service] save settings failed:', err) log.error('save settings failed:', { err: err })
} }
} }
@@ -215,17 +259,12 @@ export class AgentService {
return () => this.listeners.delete(fn) return () => this.listeners.delete(fn)
} }
/** 供 IPC 层主动广播事件(如审批结果同步到所有窗口) */
broadcastEvent(event: AgentUiEvent): void {
this.emit(event)
}
private emit(event: AgentUiEvent): void { private emit(event: AgentUiEvent): void {
for (const fn of this.listeners) { for (const fn of this.listeners) {
try { try {
fn(event) fn(event)
} catch (err) { } catch (err) {
console.error('[agent-service] listener error:', err) log.error('listener error:', { err: err })
} }
} }
} }
@@ -256,6 +295,8 @@ export class AgentService {
const cwd = this.cwd const cwd = this.cwd
const agentDir = getAgentDir() const agentDir = getAgentDir()
this.settings = this.loadSettings() this.settings = this.loadSettings()
// 项目级第三方插件目录随工作区同步(.pi/extensions;目录变更即重扫描插件列表)
getPluginHost().setProjectExtensionsDir(EXTERNAL_PLUGINS_ENABLED ? cwd : null)
// 审批拦截只需注册一次(create 会随工作区切换多次执行) // 审批拦截只需注册一次(create 会随工作区切换多次执行)
if (!this.approvalsRegistered) { if (!this.approvalsRegistered) {
this.approvalsRegistered = true this.approvalsRegistered = true
@@ -275,7 +316,20 @@ export class AgentService {
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode), createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode),
// 插件聚合 extension:全部已启用插件在同一真实 ExtensionAPI 上下文里激活 // 插件聚合 extension:全部已启用插件在同一真实 ExtensionAPI 上下文里激活
getPluginHost().createAggregatedExtension() getPluginHost().createAggregatedExtension()
] ],
// 技能/命令启停:发现阶段按 AppSettings 过滤(被禁资源不进系统提示词与 / 命令面板)。
// 全量结果先快照给设置页(被禁项在 loader 结果里不可见);闭包在每次 reload 重跑,
// 工作区切换/重建运行时后快照随之刷新。
skillsOverride: (base) => {
this.resourceSnapshotSkills = base.skills
const disabled = new Set(this.settings.disabledSkills ?? [])
return { skills: base.skills.filter((s) => !disabled.has(s.name)), diagnostics: base.diagnostics }
},
promptsOverride: (base) => {
this.resourceSnapshotPrompts = base.prompts
const disabled = new Set(this.settings.disabledCommands ?? [])
return { prompts: base.prompts.filter((p) => !disabled.has(p.name)), diagnostics: base.diagnostics }
}
} }
}) })
const initialServices = await buildServices(cwd, agentDir) const initialServices = await buildServices(cwd, agentDir)
@@ -298,6 +352,8 @@ export class AgentService {
// 业务工具(browserTools 等)由插件经 pi.registerTool 注册(聚合 extension), // 业务工具(browserTools 等)由插件经 pi.registerTool 注册(聚合 extension),
// 此处不再传 customTools // 此处不再传 customTools
}) })
// 会话创建是唯一收口点:新建/切换/分叉/重载都会经过这里,统一应用 Windows PowerShell 偏好
preferPowerShell(result.session)
return { ...result, services, diagnostics: services.diagnostics } return { ...result, services, diagnostics: services.diagnostics }
} }
@@ -471,13 +527,14 @@ export class AgentService {
* 类目:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他; * 类目:消息 / 自定义工具 / 系统工具 / 系统提示词 / 技能 / 其他;
* 技能块注入在系统提示词内,从提示词估算中拆出(不重复计数)。 */ * 技能块注入在系统提示词内,从提示词估算中拆出(不重复计数)。 */
private buildContextInfo(session: AgentSession): ContextUsageInfo { private buildContextInfo(session: AgentSession): ContextUsageInfo {
const agg = this.aggregateSession(session)
const breakdown: ContextBreakdownEntry[] = [ const breakdown: ContextBreakdownEntry[] = [
{ key: 'messages', label: '消息', tokens: 0 }, { key: 'messages', label: '消息', tokens: agg.breakdown.get('messages') ?? 0 },
{ key: 'customTools', label: '自定义工具', tokens: 0 }, { key: 'customTools', label: '自定义工具', tokens: agg.breakdown.get('customTools') ?? 0 },
{ key: 'builtinTools', label: '系统工具', tokens: 0 }, { key: 'builtinTools', label: '系统工具', tokens: agg.breakdown.get('builtinTools') ?? 0 },
{ key: 'systemPrompt', label: '系统提示词', tokens: 0 }, { key: 'systemPrompt', label: '系统提示词', tokens: 0 },
{ key: 'skills', label: '技能', tokens: 0 }, { key: 'skills', label: '技能', tokens: 0 },
{ key: 'other', label: '其他', tokens: 0 } { key: 'other', label: '其他', tokens: agg.breakdown.get('other') ?? 0 }
] ]
const byKey = new Map(breakdown.map((b) => [b.key, b])) const byKey = new Map(breakdown.map((b) => [b.key, b]))
const systemTokens = Math.ceil(session.systemPrompt.length / 4) const systemTokens = Math.ceil(session.systemPrompt.length / 4)
@@ -485,44 +542,12 @@ export class AgentService {
byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens) byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens)
byKey.get('skills')!.tokens = skillsTokens byKey.get('skills')!.tokens = skillsTokens
for (const message of session.messages) {
const m = message as { role?: string; toolName?: unknown; content?: unknown }
switch (m.role) {
case 'user':
case 'assistant':
byKey.get('messages')!.tokens += estimateMessageTokens(message)
break
case 'toolResult': {
const key =
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
byKey.get(key)!.tokens += estimateMessageTokens(message)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
case 'bashExecution':
case 'custom':
case 'compactionSummary':
case 'branchSummary':
byKey.get('other')!.tokens += estimateMessageTokens(message)
break
default:
break
}
}
const usage = session.getContextUsage() const usage = session.getContextUsage()
let lastOutputTokens: number | undefined
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i] as { role?: string; usage?: { output?: unknown } }
if (m.role === 'assistant' && m.usage && typeof m.usage.output === 'number') {
lastOutputTokens = m.usage.output
break
}
}
return { return {
tokens: usage && typeof usage.tokens === 'number' ? usage.tokens : null, tokens: usage && typeof usage.tokens === 'number' ? usage.tokens : null,
contextWindow: usage?.contextWindow ?? 0, contextWindow: usage?.contextWindow ?? 0,
percent: usage && typeof usage.percent === 'number' ? usage.percent : null, percent: usage && typeof usage.percent === 'number' ? usage.percent : null,
...(lastOutputTokens !== undefined ? { lastOutputTokens } : {}), ...(agg.lastOutputTokens !== undefined ? { lastOutputTokens: agg.lastOutputTokens } : {}),
breakdown breakdown
} }
} }
@@ -542,42 +567,114 @@ export class AgentService {
return value return value
} }
// ---- 会话统计增量聚合 ----
// emitStats 每条 assistant 消息落地都会触发;长会话下每次全量遍历估算
// token 是 O(全部消息)。消息列表正常只追加:以「已聚合前缀长度 + 前缀
// 末条消息引用」为锚点,每次只聚合新增尾部;压缩/回溯/会话切换/运行时
// 重建后消息对象引用必变(或长度回退),锚点失配即自动整体重算。
private sessionAgg: {
count: number
anchor: unknown
cacheInput: number
cacheRead: number
cacheWrite: number
toolCalls: number
toolErrors: number
/** 消息侧 breakdown(systemPrompt/skills 在外层另算) */
breakdown: Map<string, number>
lastOutputTokens: number | undefined
} | null = null
private aggregateSession(session: AgentSession) {
const messages = session.messages
let agg = this.sessionAgg
if (
agg === null ||
messages.length < agg.count ||
(agg.count > 0 && messages[agg.count - 1] !== agg.anchor)
) {
agg = {
count: 0,
anchor: null,
cacheInput: 0,
cacheRead: 0,
cacheWrite: 0,
toolCalls: 0,
toolErrors: 0,
breakdown: new Map([
['messages', 0],
['customTools', 0],
['builtinTools', 0],
['other', 0]
]),
lastOutputTokens: undefined
}
}
for (let i = agg.count; i < messages.length; i++) {
const m = messages[i] as {
role?: string
toolName?: unknown
content?: unknown
isError?: unknown
usage?: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown }
}
const bump = (key: string): void => {
agg!.breakdown.set(key, (agg!.breakdown.get(key) ?? 0) + estimateMessageTokens(messages[i]))
}
switch (m.role) {
case 'assistant': {
bump('messages')
if (m.usage) {
agg.cacheInput += Number(m.usage.input ?? 0) || 0
agg.cacheRead += Number(m.usage.cacheRead ?? 0) || 0
agg.cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0
if (typeof m.usage.output === 'number') agg.lastOutputTokens = m.usage.output
}
if (Array.isArray(m.content)) {
for (const block of m.content as Array<{ type?: unknown }>) {
if (block && typeof block === 'object' && block.type === 'toolCall') agg.toolCalls += 1
}
}
break
}
case 'user':
bump('messages')
break
case 'toolResult': {
if (m.isError === true) agg.toolErrors += 1
bump(
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
case 'bashExecution':
case 'custom':
case 'compactionSummary':
case 'branchSummary':
bump('other')
break
default:
break
}
}
agg.count = messages.length
agg.anchor = messages.length > 0 ? messages[messages.length - 1] : null
this.sessionAgg = agg
return agg
}
private async emitStats(): Promise<void> { private async emitStats(): Promise<void> {
try { try {
const runtime = await this.getRuntime() const runtime = await this.getRuntime()
const session = runtime.session const session = runtime.session
const stats = session.getSessionStats() const stats = session.getSessionStats()
const usage = session.getContextUsage() const usage = session.getContextUsage()
const agg = this.aggregateSession(session)
// 缓存命中率:读缓存 / 全部提示 token(input + cacheRead + cacheWrite), // 缓存命中率:读缓存 / 全部提示 token(input + cacheRead + cacheWrite),
// 按本会话所有 assistant 响应聚合,0 表示会话内尚无任何响应 // 按本会话所有 assistant 响应聚合,0 表示会话内尚无任何响应
let cacheInput = 0 const denominator = agg.cacheInput + agg.cacheRead + agg.cacheWrite
let cacheRead = 0
let cacheWrite = 0
let toolCalls = 0
let toolErrors = 0
for (const message of session.messages) {
const m = message as {
role?: string
usage?: { input?: unknown; cacheRead?: unknown; cacheWrite?: unknown }
content?: unknown
isError?: unknown
}
if (m.role === 'assistant') {
if (m.usage) {
cacheInput += Number(m.usage.input ?? 0) || 0
cacheRead += Number(m.usage.cacheRead ?? 0) || 0
cacheWrite += Number(m.usage.cacheWrite ?? 0) || 0
}
if (Array.isArray(m.content)) {
for (const block of m.content as Array<{ type?: unknown }>) {
if (block && typeof block === 'object' && block.type === 'toolCall') toolCalls += 1
}
}
} else if (m.role === 'toolResult' && m.isError === true) {
toolErrors += 1
}
}
const denominator = cacheInput + cacheRead + cacheWrite
const info: SessionStatsInfo = { const info: SessionStatsInfo = {
tokens: { ...stats.tokens }, tokens: { ...stats.tokens },
cost: stats.cost, cost: stats.cost,
@@ -585,15 +682,15 @@ export class AgentService {
context: this.buildContextInfo(session), context: this.buildContextInfo(session),
...(denominator > 0 ...(denominator > 0
? { ? {
cacheHitRate: Math.round((cacheRead / denominator) * 100), cacheHitRate: Math.round((agg.cacheRead / denominator) * 100),
cacheTokens: { read: cacheRead, write: cacheWrite, input: cacheInput } cacheTokens: { read: agg.cacheRead, write: agg.cacheWrite, input: agg.cacheInput }
} }
: {}), : {}),
...(toolCalls > 0 ...(agg.toolCalls > 0
? { ? {
toolStats: { toolStats: {
calls: toolCalls, calls: agg.toolCalls,
successRate: Math.round(((toolCalls - toolErrors) / toolCalls) * 100) successRate: Math.round(((agg.toolCalls - agg.toolErrors) / agg.toolCalls) * 100)
} }
} }
: {}) : {})
@@ -651,7 +748,8 @@ export class AgentService {
} }
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。 /** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 */ * 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。
* 数据源用 override 写入的全量快照:被禁资源在会话里已过滤,只有快照还看得到。 */
async listResourceDetails(): Promise<ResourceDetails> { async listResourceDetails(): Promise<ResourceDetails> {
const agentDir = getAgentDir() const agentDir = getAgentDir()
const dirs = { const dirs = {
@@ -662,42 +760,64 @@ export class AgentService {
globalCommands: join(agentDir, 'prompts'), globalCommands: join(agentDir, 'prompts'),
projectCommands: join(this.cwd, '.pi', 'prompts') projectCommands: join(this.cwd, '.pi', 'prompts')
} }
const disabledSkills = new Set(this.settings.disabledSkills ?? [])
const disabledCommands = new Set(this.settings.disabledCommands ?? [])
const skills: SkillDetail[] = [] const skills: SkillDetail[] = []
try { try {
const runtime = await this.getRuntime() const runtime = await this.getRuntime()
for (const s of runtime.session.resourceLoader.getSkills().skills) { const all = this.resourceSnapshotSkills.length > 0 ? this.resourceSnapshotSkills : runtime.session.resourceLoader.getSkills().skills
for (const s of all) {
skills.push({ skills.push({
name: s.name, name: s.name,
description: s.description, description: s.description,
filePath: s.filePath, filePath: s.filePath,
// 与 resource loader 的默认归类一致:无来源信息时按用户级 // 与 resource loader 的默认归类一致:无来源信息时按用户级
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user', scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
disableModelInvocation: Boolean(s.disableModelInvocation) disableModelInvocation: Boolean(s.disableModelInvocation),
disabled: disabledSkills.has(s.name)
}) })
} }
} catch (err) { } catch (err) {
console.error('[agent-service] load skill details failed:', err) log.error('load skill details failed:', { err: err })
} }
const commands: CommandDetail[] = [] const commands: CommandDetail[] = []
try { try {
const runtime = await this.getRuntime() const runtime = await this.getRuntime()
const all =
this.resourceSnapshotPrompts.length > 0 ? this.resourceSnapshotPrompts : runtime.session.promptTemplates
commands.push( commands.push(
...runtime.session.promptTemplates.map((t) => ({ ...all.map((t) => ({
name: t.name, name: t.name,
description: t.description ?? '', description: t.description ?? '',
argumentHint: t.argumentHint || undefined, argumentHint: t.argumentHint || undefined,
filePath: t.filePath, filePath: t.filePath,
scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const) scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const),
disabled: disabledCommands.has(t.name)
})) }))
) )
} catch (err) { } catch (err) {
console.error('[agent-service] list command details failed:', err) log.error('list command details failed:', { err: err })
} }
skills.sort((a, b) => a.name.localeCompare(b.name)) skills.sort((a, b) => a.name.localeCompare(b.name))
commands.sort((a, b) => a.name.localeCompare(b.name)) commands.sort((a, b) => a.name.localeCompare(b.name))
return { skills, commands, dirs } return { skills, commands, dirs }
} }
/** 启用/禁用单个技能或命令:持久化到 AppSettings 后重建运行时(发现阶段过滤随之生效) */
async setResourceEnabled(kind: 'skill' | 'command', name: string, enabled: boolean): Promise<void> {
const trimmed = name.trim()
if (!trimmed) throw new Error('资源名不能为空')
const field = kind === 'skill' ? ('disabledSkills' as const) : ('disabledCommands' as const)
const next = new Set(this.settings[field] ?? [])
if (enabled) next.delete(trimmed)
else next.add(trimmed)
this.settings = { ...this.settings, [field]: [...next].sort((a, b) => a.localeCompare(b)) }
this.saveSettings()
await this.rebuildRuntime()
const session = this.runtime?.session
if (session) this.emit({ type: 'state', state: this.buildState(session) })
}
// ---- 工作区文件(@ 引用)---- // ---- 工作区文件(@ 引用)----
async listWorkspaceFiles(): Promise<WorkspaceFile[]> { async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
@@ -765,7 +885,7 @@ export class AgentService {
} }
return { name, path, mimeType, data: readFileSync(path).toString('base64') } return { name, path, mimeType, data: readFileSync(path).toString('base64') }
} catch (err) { } catch (err) {
console.error('[agent-service] read attachment failed:', err) log.error('read attachment failed:', { err: err })
return { name, path, mimeType: '', data: '' } return { name, path, mimeType: '', data: '' }
} }
} }
@@ -780,7 +900,7 @@ export class AgentService {
} }
} }
} catch (err) { } catch (err) {
console.error('[agent-service] read attachment failed:', err) log.error('read attachment failed:', { err: err })
} }
return { name, path, mimeType: '', data: '' } return { name, path, mimeType: '', data: '' }
} }
@@ -1130,20 +1250,59 @@ export class AgentService {
} }
} }
/** 重建运行时(插件重载/启停后由 PluginHost 调用,extensionFactories 重跑) */ /** 重建运行时(插件重载/启停、工作区切换时调用,extensionFactories 重跑)。
async rebuildRuntime(): Promise<void> { * restoreSession=true(插件重载):重建对渲染层尽量透明——
* - 进行中的回合先经 abort 正常结算(事件照常送达,渲染层自行收口),
* 再退订/销毁旧运行时——dispose 不含 abort,直接销毁会留下悬空请求;
* - 重建走 continueRecent(最近会话):用户开着的不是它时切回原会话;
* 原会话是未落盘的空新会话时退回空会话,避免静默接管别的会话;
* - 会话未变化时只推轻量 state,不广播 session_switched(该事件语义
* 保留给真正的会话变更,全量换血会重挂载渲染层全部消息)。
* restoreSession=false(工作区切换):新 cwd 的最近会话即目标状态,
* 直接全量广播切换。 */
async rebuildRuntime(options?: { restoreSession?: boolean }): Promise<void> {
const restoreSession = options?.restoreSession !== false
const old = this.runtime const old = this.runtime
this.runtime = null this.runtime = null
this.initPromise = null this.initPromise = null
if (old?.session.isStreaming) {
await old.session.abort().catch(() => undefined)
}
// abort 结算会落盘当前回合,未持久会话的文件路径此时才物化——在其后读取
const previousPath = old?.session.sessionFile ?? null
this.unsubscribeAttached?.() this.unsubscribeAttached?.()
this.unsubscribeAttached = null this.unsubscribeAttached = null
this.attached = null this.attached = null
if (old) { if (old) {
await old.dispose().catch((err: unknown) => { await old.dispose().catch((err: unknown) => {
console.error('[agent-service] runtime dispose error:', err) log.error('runtime dispose error:', { err: err })
}) })
} }
await this.ensureReady() let runtime: AgentSessionRuntime
try {
runtime = await this.getRuntime()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
this.emit({ type: 'session_error', message })
throw err
}
if (!restoreSession) {
this.emitSwitched(runtime.session)
return
}
const restored = runtime.session.sessionFile ?? ''
if (previousPath) {
if (!samePath(restored, previousPath) && existsSync(previousPath)) {
// 被拽到了最近会话:切回用户原本打开的会话(rebind 发 session_switched,真实变更)
await runtime.switchSession(previousPath)
return
}
} else if (runtime.session.messages.length > 0) {
// 原会话是未落盘的空新会话:不接管最近会话,回到空会话
await runtime.newSession()
return
}
this.emit({ type: 'state', state: this.buildState(runtime.session) })
} }
} }
@@ -15,8 +15,11 @@ export const browserPlugin: BuiltinPlugin = {
minContract: '0.1.0', minContract: '0.1.0',
panel: true, panel: true,
order: 10, order: 10,
description: '受管浏览器(CDP 驱动,Agent 可操作,右侧面板查看)' description: '受管浏览器',
icon: 'Globe'
}, },
/** 宿主专属二级设置页(代理设置;见 SettingsModal 插件页特判) */
settingsPage: true,
main: (api) => { main: (api) => {
for (const tool of browserTools) { for (const tool of browserTools) {
api.pi.registerTool(tool) api.pi.registerTool(tool)
@@ -14,6 +14,9 @@ export interface BuiltinPlugin {
main?: HarnessPluginFactory main?: HarnessPluginFactory
/** 渲染层宿主树组件 key(renderer/src/plugins/builtin-components.ts) */ /** 渲染层宿主树组件 key(renderer/src/plugins/builtin-components.ts) */
component?: string component?: string
/** 带宿主专属二级设置页(设置页插件行可点击进入、可固定一级入口;
* 不走声明式设置表单,页面内容由设置页特判渲染) */
settingsPage?: true
} }
export const BUILTIN_PLUGINS: BuiltinPlugin[] = [browserPlugin] export const BUILTIN_PLUGINS: BuiltinPlugin[] = [browserPlugin]
+631 -120
View File
@@ -1,31 +1,63 @@
/** /**
* 插件宿主(主进程): * 插件宿主(主进程):
* - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle * - 内置插件:代码注册(manifest + 第一方工厂 + 组件 key),编译进 bundle
* - 外置插件:userData/plugins/<id>/manifest.json + 可选 main.mjs(动态 import) * - 外置插件:userData/plugins/<id>、项目 .pi/extensions/<id>、全局 ~/.pi/agent/extensions/<id>
* - 同 id 高 semver 胜、平级外置胜;外置遮蔽组件级内置需 manifest.replacesNative * (均为 manifest.json + 可选 main.mjs / entry.renderer / entry.page)
* - 同 id 高 semver 胜、平级先扫者胜(userData > 项目 > 全局);外置遮蔽组件级内置需
* manifest.replacesNative
* - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到 * - 聚合为一个 InlineExtension 挂进 pi extensionFactories:插件工厂收到
* { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径) * { pi, ui, storage, manifest },pi 为真实 ExtensionAPI(工具/事件/命令走原生路径)
* - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层 * - 内联 UI(状态胶囊/工具栏按钮)数据存主进程(单一事实源),变更广播渲染层
* - plugin:// 协议把插件目录映射给 iframe 页(含路径穿越防护) * - plugin:// 协议把插件目录映射给 iframe 页与渲染层 renderer 模块(含路径穿越防护)
* - entry.renderer(渲染层 React 组件模块)在渲染层经动态 import 装载,React 用宿主实例
*
* 外置装载是 build 级开关:EXTERNAL_PLUGINS_ENABLED——开发版(未打包)开启,
* 用户版(打包)关闭(M5 ed25519 验签 + 开发者模式落地后再放开;QA 可用
* ASO_EXTERNAL_PLUGINS=1 豁免)。关闭时仅内置插件生效。
* *
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入 * 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
* ed25519 验签 + 开发者模式豁免)。 * ed25519 验签 + 开发者模式豁免)。
*/ */
import { app, BrowserWindow, net, protocol } from 'electron' import { app, BrowserWindow, net, protocol, shell } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join, resolve, sep } from 'node:path' import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import { getAgentDir } from '@earendil-works/pi-coding-agent'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent' import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
import type { HarnessPluginFactory, PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk' import type {
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types' HarnessPluginFactory,
PluginManifest,
PluginSettingsSchema,
PluginSettingValues,
PluginSettingsSchemas,
PluginSettingsValuesMap,
PluginStorageSchema,
StatusItem,
ToolbarButtonDef
} from '@aso/plugin-sdk'
import { PLUGIN_ID_PATTERN, withTimeout } from '@aso/plugin-sdk'
import type { PluginDepStatus, PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin' import { BUILTIN_PLUGINS, type BuiltinPlugin } from './builtin'
import { PluginStorageService } from './plugin-storage'
import { getLogger } from '../logger'
const log = getLogger('plugin-host')
const pluginLog = getLogger('plugin')
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */ /** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
export const HARNESS_CONTRACT_VERSION = '0.1.0' export const HARNESS_CONTRACT_VERSION = '0.3.0'
/** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */
export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000
/** 外置插件装载开关(build 级):开发版开启、用户版关闭;ASO_EXTERNAL_PLUGINS=1 强制开启 */
export const EXTERNAL_PLUGINS_ENABLED = !app.isPackaged || process.env.ASO_EXTERNAL_PLUGINS === '1'
interface ResolvedPlugin { interface ResolvedPlugin {
manifest: PluginManifest manifest: PluginManifest
source: 'builtin' | 'external' source: 'builtin' | 'external'
/** 外置来源目录类别(管理页分组展示用) */
origin?: 'user' | 'project' | 'global'
/** 外置插件根目录(plugin:// 映射根) */ /** 外置插件根目录(plugin:// 映射根) */
dir?: string dir?: string
/** 内置:第一方工厂 / 渲染层组件 key */ /** 内置:第一方工厂 / 渲染层组件 key */
@@ -54,17 +86,60 @@ function compareSemver(a: string, b: string): number {
return 0 return 0
} }
/** semver 范围判定:支持 `*` / `^x.y.z` / `~x.y.z` / `>=x.y.z` / 精确 `x.y.z`(忽略预发布段) */
function satisfiesRange(version: string, range: string): boolean {
const v = parseSemver(version)
if (!v) return false
const r = range.trim()
if (!r || r === '*') return true
const m = /^(>=|\^|~)?\s*(\d+)\.(\d+)\.(\d+)$/.exec(r)
if (!m) return false
const cmp = compareSemver(version, `${m[2]}.${m[3]}.${m[4]}`)
if (m[1] === '^') return cmp >= 0 && v[0] === Number(m[2])
if (m[1] === '~') return cmp >= 0 && v[0] === Number(m[2]) && v[1] === Number(m[3])
if (m[1] === '>=') return cmp >= 0
return cmp === 0
}
/** 依赖声明清洗:键须为合法插件 id、值须为受支持的范围语法;不合法条目丢弃(激活期按缺失处理) */
function sanitizeDeps(raw: unknown): Record<string, string> | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
const out: Record<string, string> = {}
for (const [id, range] of Object.entries(raw as Record<string, unknown>)) {
if (!PLUGIN_ID_PATTERN.test(id) || typeof range !== 'string') continue
const trimmed = range.trim()
if (!/^(\*|(>=|\^|~)?\d+\.\d+\.\d+)$/.test(trimmed)) continue
out[id] = trimmed
}
return Object.keys(out).length > 0 ? out : undefined
}
class PluginHost { class PluginHost {
private resolved = new Map<string, ResolvedPlugin>() private resolved = new Map<string, ResolvedPlugin>()
private disabled = new Set<string>() private disabled = new Set<string>()
/** 项目级扩展目录(.pi/extensions,随工作区切换;由 agent-service 同步) */
private projectExtensionsDir: string | null = null
/** 插件设置:声明 schema(激活时重新声明)/ 用户改过的值 / 一级导航开关 */
private settingsSchemas = new Map<string, PluginSettingsSchema>()
private settingsValues = new Map<string, PluginSettingValues>()
private settingsNavSet = new Set<string>()
/** 设置页动作/值变更的激活期回调(每次插件激活时重置) */
private settingsActions = new Map<string, Set<(key: string) => void>>()
private settingsListeners = new Map<string, Set<(values: PluginSettingValues) => void>>()
private valuesSaveTimer: NodeJS.Timeout | null = null
/** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */ /** runtime 重建回调(由 ipc 层注入:extensionFactories 重跑 = 插件重新激活) */
private rebuildRuntime: (() => Promise<void>) | null = null private rebuildRuntime: (() => Promise<void>) | null = null
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */ /** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
private statusItems = new Map<string, Map<string, StatusItem>>() private statusItems = new Map<string, Map<string, StatusItem>>()
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>() private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
/** 插件 KV 存储(pluginId → key → value) */ /** 状态条目最近写入时间(悬浮胶囊"最新消息"判定),key = pluginId\0itemId */
private storage = new Map<string, Map<string, unknown>>() private statusUpdatedAt = new Map<string, number>()
private storageSaveTimer: NodeJS.Timeout | null = null /** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */
private services = new Map<string, Map<string, unknown>>()
/** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */
private storageSchemas = new Map<string, PluginStorageSchema>()
/** 插件 SQLite 存储服务(共享 worker;init 时创建) */
private storageService: PluginStorageService | null = null
private get externalRoot(): string { private get externalRoot(): string {
return join(app.getPath('userData'), 'plugins') return join(app.getPath('userData'), 'plugins')
@@ -74,8 +149,8 @@ class PluginHost {
return join(app.getPath('userData'), 'aso-plugin-settings.json') return join(app.getPath('userData'), 'aso-plugin-settings.json')
} }
private get storageFile(): string { private get valuesFile(): string {
return join(app.getPath('userData'), 'aso-plugin-storage.json') return join(app.getPath('userData'), 'aso-plugin-values.json')
} }
// ---- 生命周期 ---- // ---- 生命周期 ----
@@ -86,21 +161,28 @@ class PluginHost {
} catch { } catch {
// 已存在或创建失败都继续(扫描时再报) // 已存在或创建失败都继续(扫描时再报)
} }
this.storageService = new PluginStorageService(join(app.getPath('userData'), 'plugin-data'))
log.info('plugin host init', {
externalRoot: this.externalRoot,
externalLoadingEnabled: EXTERNAL_PLUGINS_ENABLED
})
try { try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown } const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as { disabled?: unknown; settingsNav?: unknown }
if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string')) if (Array.isArray(raw.disabled)) this.disabled = new Set(raw.disabled.filter((d): d is string => typeof d === 'string'))
if (Array.isArray(raw.settingsNav))
this.settingsNavSet = new Set(raw.settingsNav.filter((d): d is string => typeof d === 'string'))
} catch { } catch {
// 无设置文件 = 全部启用 // 无设置文件 = 全部启用
} }
try { try {
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown> const raw = JSON.parse(readFileSync(this.valuesFile, 'utf8')) as Record<string, unknown>
for (const [pid, kv] of Object.entries(raw)) { for (const [pid, kv] of Object.entries(raw)) {
if (kv && typeof kv === 'object' && !Array.isArray(kv)) { if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
this.storage.set(pid, new Map(Object.entries(kv as Record<string, unknown>))) this.settingsValues.set(pid, { ...(kv as PluginSettingValues) })
} }
} }
} catch { } catch {
// 无存储文件 = 空 // 无值文件 = 空
} }
void this.scan() void this.scan()
} }
@@ -109,7 +191,16 @@ class PluginHost {
this.rebuildRuntime = fn this.rebuildRuntime = fn
} }
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建) */ /** 同步项目级扩展目录(工作区切换时变更即重扫描;仅通知 UI,不重建 runtime) */
setProjectExtensionsDir(dir: string | null): void {
const next = dir ?? null
if ((this.projectExtensionsDir ?? null) === next) return
this.projectExtensionsDir = next
void this.scan()
}
/** 扫描内置注册表 + 外置目录并合并(不动已激活状态;reload 才重建)。
* 外置源优先级:userData/plugins > 项目 .pi/extensions > 全局 ~/.pi/agent/extensions。 */
async scan(): Promise<void> { async scan(): Promise<void> {
const byId = new Map<string, ResolvedPlugin>() const byId = new Map<string, ResolvedPlugin>()
@@ -122,44 +213,68 @@ class PluginHost {
}) })
} }
if (existsSync(this.externalRoot)) { if (EXTERNAL_PLUGINS_ENABLED) {
let entries: import('node:fs').Dirent[] const roots: Array<{ root: string; origin: 'user' | 'project' | 'global' }> = [
try { { root: this.externalRoot, origin: 'user' }
entries = readdirSync(this.externalRoot, { withFileTypes: true }) ]
} catch { if (this.projectExtensionsDir) roots.push({ root: this.projectExtensionsDir, origin: 'project' })
entries = [] roots.push({ root: join(getAgentDir(), 'extensions'), origin: 'global' })
} for (const { root, origin } of roots) {
for (const entry of entries) { if (!existsSync(root)) continue
if (!entry.isDirectory()) continue let entries: import('node:fs').Dirent[]
const dir = join(this.externalRoot, entry.name) try {
const manifest = this.readManifest(dir) entries = readdirSync(root, { withFileTypes: true })
if (!manifest) continue } catch {
const existing = byId.get(manifest.id) entries = []
if (existing) { }
const cmp = compareSemver(manifest.version, existing.manifest.version) for (const entry of entries) {
// 外置仅在严格更高版本时遮蔽内置;平级外置胜仅适用于同为外置的场景(此处 existing 必为内置) if (!entry.isDirectory()) continue
if (cmp <= 0) continue const dir = join(root, entry.name)
// 组件级内置只能被显式声明降级替代的外置遮蔽 const manifest = this.readManifest(dir)
if (existing.componentKey && !manifest.replacesNative) { if (!manifest) continue
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}` const existing = byId.get(manifest.id)
if (existing) {
const cmp = compareSemver(manifest.version, existing.manifest.version)
// 仅严格更高版本可遮蔽;平级先扫者胜
if (cmp <= 0) continue
// 组件级内置只能被显式声明降级替代的外置遮蔽
if (existing.componentKey && !manifest.replacesNative) {
existing.error = `外置 ${manifest.version} 未声明 replacesNative,继续使用内置 ${existing.manifest.version}`
continue
}
}
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
byId.set(manifest.id, {
manifest,
source: 'external',
dir,
origin,
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
})
continue continue
} }
byId.set(manifest.id, { manifest, source: 'external', dir, origin })
} }
if (compareSemver(manifest.minContract, HARNESS_CONTRACT_VERSION) > 0) {
byId.set(manifest.id, {
manifest,
source: 'external',
dir,
error: `需要契约版本 >= ${manifest.minContract}(当前 ${HARNESS_CONTRACT_VERSION})`
})
continue
}
byId.set(manifest.id, { manifest, source: 'external', dir })
} }
} }
this.resolved = byId this.resolved = byId
// 清掉已卸载插件的设置 schema(值保留在盘上,重装后自动回来)
for (const id of [...this.settingsSchemas.keys()]) {
if (!byId.has(id)) {
this.settingsSchemas.delete(id)
this.dropSettingsCallbacks(id)
}
}
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
await this.loadExternalMains() await this.loadExternalMains()
const externalCount = [...byId.values()].filter((p) => p.source === 'external').length
log.info('scan complete', {
builtin: byId.size - externalCount,
external: externalCount,
disabled: this.disabled.size,
projects: this.projectExtensionsDir ?? '-'
})
this.send('plugins:listChanged', null) this.send('plugins:listChanged', null)
} }
@@ -183,10 +298,13 @@ class PluginHost {
raw.entry && typeof raw.entry === 'object' raw.entry && typeof raw.entry === 'object'
? { ? {
page: typeof raw.entry.page === 'string' ? raw.entry.page : undefined, page: typeof raw.entry.page === 'string' ? raw.entry.page : undefined,
main: typeof raw.entry.main === 'string' ? raw.entry.main : undefined main: typeof raw.entry.main === 'string' ? raw.entry.main : undefined,
renderer: typeof raw.entry.renderer === 'string' ? raw.entry.renderer : undefined
} }
: undefined, : undefined,
replacesNative: raw.replacesNative === true, replacesNative: raw.replacesNative === true,
dependencies: sanitizeDeps(raw.dependencies),
optionalDependencies: sanitizeDeps(raw.optionalDependencies),
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
} }
} catch { } catch {
@@ -204,66 +322,240 @@ class PluginHost {
const mod = (await import(url)) as { default?: unknown } const mod = (await import(url)) as { default?: unknown }
if (typeof mod.default !== 'function') { if (typeof mod.default !== 'function') {
plugin.error = 'main.mjs 默认导出必须是插件工厂函数' plugin.error = 'main.mjs 默认导出必须是插件工厂函数'
log.error('external main invalid export', { pluginId: plugin.manifest.id, file })
continue continue
} }
plugin.externalFactory = mod.default as HarnessPluginFactory plugin.externalFactory = mod.default as HarnessPluginFactory
plugin.error = undefined plugin.error = undefined
log.info('external main loaded', { pluginId: plugin.manifest.id, version: plugin.manifest.version })
} catch (err) { } catch (err) {
plugin.externalFactory = undefined plugin.externalFactory = undefined
plugin.error = `main 加载失败: ${err instanceof Error ? err.message : String(err)}` plugin.error = `main 加载失败: ${err instanceof Error ? err.message : String(err)}`
log.error('external main load failed', { pluginId: plugin.manifest.id, file, err })
} }
} }
} }
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */ /** 重新扫描 + 重新导入 + 清 UI/服务/存储声明 + 重建 runtime(= pi /reload 等效) */
async reload(): Promise<void> { async reload(): Promise<void> {
log.info('reload begin')
this.statusItems.clear() this.statusItems.clear()
this.toolbarButtons.clear() this.toolbarButtons.clear()
this.statusUpdatedAt.clear()
this.services.clear()
this.storageSchemas.clear()
await this.scan() await this.scan()
this.send('plugins:uiState', this.uiState()) this.send('plugins:uiState', this.uiState())
this.send('plugins:listChanged', null)
if (this.rebuildRuntime) await this.rebuildRuntime() if (this.rebuildRuntime) await this.rebuildRuntime()
log.info('reload done')
} }
async setEnabled(id: string, enabled: boolean): Promise<void> { async setEnabled(id: string, enabled: boolean): Promise<void> {
log.info('set plugin enabled', { pluginId: id, enabled })
if (enabled) this.disabled.delete(id) if (enabled) this.disabled.delete(id)
else this.disabled.add(id) else this.disabled.add(id)
try { this.persistPluginPrefs()
writeFileSync(this.settingsFile, JSON.stringify({ disabled: [...this.disabled] }, null, 2), 'utf8')
} catch (err) {
console.error('[plugins] save settings failed:', err)
}
await this.reload() await this.reload()
} }
/** 设置入口是否固定到设置页一级导航(仅声明了设置表单的插件可开) */
async setSettingsNav(id: string, on: boolean): Promise<void> {
const plugin = this.resolved.get(id)
// 可固定入口 = 声明了设置表单,或内置插件带宿主专属设置页(如浏览器)
if (!plugin || (!this.settingsSchemas.has(id) && !this.builtinSettingsPage(id))) return
log.info('set settings nav', { pluginId: id, on })
if (on) this.settingsNavSet.add(id)
else this.settingsNavSet.delete(id)
this.persistPluginPrefs()
this.send('plugins:listChanged', null)
}
private persistPluginPrefs(): void {
try {
writeFileSync(
this.settingsFile,
JSON.stringify({ disabled: [...this.disabled], settingsNav: [...this.settingsNavSet] }, null, 2),
'utf8'
)
} catch (err) {
log.error('save plugin prefs failed', { err })
}
}
// ---- 插件设置表单(声明式 schema + 值持久化 + 动作/变更回推) ----
/** 激活期回调容器重置(每次插件工厂执行前调用) */
private resetSettingsCallbacks(id: string): void {
this.settingsActions.delete(id)
this.settingsListeners.delete(id)
}
private dropSettingsCallbacks(id: string): void {
this.settingsActions.delete(id)
this.settingsListeners.delete(id)
}
private settingsSchemasSnapshot(): PluginSettingsSchemas {
return Object.fromEntries(this.settingsSchemas)
}
private settingsValuesSnapshot(): PluginSettingsValuesMap {
const out: PluginSettingsValuesMap = {}
for (const [pid, schema] of this.settingsSchemas) {
const stored = this.settingsValues.get(pid) ?? {}
// 默认值兜底:未设置过的键回落 schema default
const defaults: PluginSettingValues = {}
for (const item of schema.items) {
if ('default' in item && item.default !== undefined && !(item.key in stored)) defaults[item.key] = item.default
}
out[pid] = { ...defaults, ...stored }
}
return out
}
settingsSchemasList(): PluginSettingsSchemas {
return this.settingsSchemasSnapshot()
}
settingsValuesList(): PluginSettingsValuesMap {
return this.settingsValuesSnapshot()
}
setSettingValue(id: string, key: string, value: unknown): void {
if (!this.settingsSchemas.has(id)) return
const kv = { ...(this.settingsValues.get(id) ?? {}), [key]: value }
this.settingsValues.set(id, kv)
this.scheduleValuesSave()
this.send('plugins:settingsValues', this.settingsValuesSnapshot())
for (const fn of this.settingsListeners.get(id) ?? []) {
try {
fn({ ...(this.settingsValues.get(id) ?? {}) })
} catch (err) {
log.error('settings listener error', { pluginId: id, err })
}
}
}
settingsAction(id: string, key: string): void {
log.debug('settings action', { pluginId: id, key })
for (const fn of this.settingsActions.get(id) ?? []) {
try {
fn(key)
} catch (err) {
log.error('settings action error', { pluginId: id, key, err })
}
}
}
private scheduleValuesSave(): void {
if (this.valuesSaveTimer) return
this.valuesSaveTimer = setTimeout(() => {
this.valuesSaveTimer = null
const doc: Record<string, PluginSettingValues> = {}
for (const [pid, kv] of this.settingsValues) doc[pid] = kv
try {
writeFileSync(this.valuesFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
log.error('save values failed', { err })
}
}, 300)
}
// ---- 查询 ---- // ---- 查询 ----
isEnabled(id: string): boolean { isEnabled(id: string): boolean {
return !this.disabled.has(id) return !this.disabled.has(id)
} }
/** 内置插件是否带宿主专属二级设置页(不走声明式表单,设置页特判渲染) */
private builtinSettingsPage(id: string): boolean {
return BUILTIN_PLUGINS.some((b) => b.manifest.id === id && b.settingsPage === true)
}
list(): PluginInfo[] { list(): PluginInfo[] {
return [...this.resolved.values()] return [...this.resolved.values()]
.map((p) => ({ .map((p) => ({
manifest: p.manifest, manifest: p.manifest,
source: p.source, source: p.source,
origin: p.origin,
enabled: this.isEnabled(p.manifest.id), enabled: this.isEnabled(p.manifest.id),
hasMain: Boolean(p.builtinFactory ?? p.externalFactory), hasMain: Boolean(p.builtinFactory ?? p.externalFactory),
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page), hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
hasComponent: Boolean(p.componentKey), hasComponent: Boolean(p.componentKey),
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
hasSettings:
(this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0 ||
this.builtinSettingsPage(p.manifest.id),
settingsNav:
this.settingsNavSet.has(p.manifest.id) &&
((this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0 ||
this.builtinSettingsPage(p.manifest.id)),
dependencies: this.depStatus(p),
storageSchema: this.storageSchemas.get(p.manifest.id),
error: p.error error: p.error
})) }))
.sort((a, b) => a.manifest.id.localeCompare(b.manifest.id)) .sort((a, b) => a.manifest.id.localeCompare(b.manifest.id))
} }
/** 依赖清单求值(管理页 Tooltip 数据源):必需 + 可选,逐项实时状态 */
private depStatus(p: ResolvedPlugin): PluginDepStatus[] {
const rows: PluginDepStatus[] = []
const add = (rec: Record<string, string> | undefined, required: boolean): void => {
for (const [depId, range] of Object.entries(rec ?? {})) {
const dep = this.resolved.get(depId)
let status: PluginDepStatus['status'] = 'ok'
if (!dep) status = 'missing'
else if (!this.isEnabled(depId)) status = 'disabled'
else if (dep.error) status = 'error'
else if (!satisfiesRange(dep.manifest.version, range)) status = 'version'
rows.push({ id: depId, range, required, status })
}
}
add(p.manifest.dependencies, true)
add(p.manifest.optionalDependencies, false)
return rows
}
getComponentKey(id: string): string | undefined { getComponentKey(id: string): string | undefined {
const p = this.resolved.get(id) const p = this.resolved.get(id)
return p?.componentKey return p?.componentKey
} }
/** 外置插件来源目录(管理页「打开目录」用) */
pluginDirs(): { user: string[]; project: string[]; global: string[] } {
return {
user: [this.externalRoot],
project: this.projectExtensionsDir ? [this.projectExtensionsDir] : [],
global: [join(getAgentDir(), 'extensions')]
}
}
/** 读取外置插件渲染层模块源码。渲染层取回后以 Blob URL import——
* dev 下页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦截。 */
readRendererSource(id: string): string {
if (!EXTERNAL_PLUGINS_ENABLED) throw new Error('第三方插件装载已关闭(用户版构建)')
const plugin = this.resolved.get(id)
const file =
plugin?.source === 'external' && plugin.manifest.entry?.renderer
? resolve(plugin.dir!, plugin.manifest.entry.renderer)
: null
if (!file || !existsSync(file)) throw new Error(`插件 ${id} 未提供渲染层模块`)
if (statSync(file).size > 8 * 1024 * 1024) throw new Error('渲染层模块超过 8MB 上限')
return readFileSync(file, 'utf8')
}
uiState(): PluginsUiState { uiState(): PluginsUiState {
const status: PluginStatusEntry[] = [] const status: PluginStatusEntry[] = []
for (const [pluginId, items] of this.statusItems) { for (const [pluginId, items] of this.statusItems) {
for (const [itemId, item] of items) status.push({ pluginId, itemId, item }) for (const [itemId, item] of items) {
status.push({
pluginId,
itemId,
item,
updatedAt: this.statusUpdatedAt.get(`${pluginId}\0${itemId}`) ?? Date.now()
})
}
} }
const toolbar: PluginToolbarEntry[] = [] const toolbar: PluginToolbarEntry[] = []
for (const [pluginId, buttons] of this.toolbarButtons) { for (const [pluginId, buttons] of this.toolbarButtons) {
@@ -281,8 +573,14 @@ class PluginHost {
items = new Map() items = new Map()
this.statusItems.set(pluginId, items) this.statusItems.set(pluginId, items)
} }
if (item) items.set(itemId, item) const stampKey = `${pluginId}\0${itemId}`
else items.delete(itemId) if (item) {
items.set(itemId, item)
this.statusUpdatedAt.set(stampKey, Date.now())
} else {
items.delete(itemId)
this.statusUpdatedAt.delete(stampKey)
}
if (items.size === 0) this.statusItems.delete(pluginId) if (items.size === 0) this.statusItems.delete(pluginId)
this.send('plugins:uiState', this.uiState()) this.send('plugins:uiState', this.uiState())
} }
@@ -306,11 +604,20 @@ class PluginHost {
this.send('plugins:uiState', this.uiState()) this.send('plugins:uiState', this.uiState())
} }
/** 清空某插件的全部内联 UI(禁用/重载路径) */ /** 清空某插件的内联 UI/服务注册/存储声明(禁用/重载/激活失败路径);设置 schema 一并撤下(导航项随之消失) */
private clearPluginUi(pluginId: string): void { private clearPluginUi(pluginId: string): void {
this.statusItems.delete(pluginId) this.statusItems.delete(pluginId)
this.toolbarButtons.delete(pluginId) this.toolbarButtons.delete(pluginId)
for (const key of [...this.statusUpdatedAt.keys()]) {
if (key.startsWith(`${pluginId}\0`)) this.statusUpdatedAt.delete(key)
}
this.settingsSchemas.delete(pluginId)
this.dropSettingsCallbacks(pluginId)
this.services.delete(pluginId)
this.storageSchemas.delete(pluginId)
this.send('plugins:uiState', this.uiState()) this.send('plugins:uiState', this.uiState())
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
this.send('plugins:listChanged', null)
} }
// ---- 深链 ---- // ---- 深链 ----
@@ -319,98 +626,296 @@ class PluginHost {
this.send('plugins:view', { view, params }) this.send('plugins:view', { view, params })
} }
// ---- 插件 KV 存储 ---- // ---- 插件数据目录(设置页「打开数据目录 / 清除数据」) ----
storageFor(pluginId: string) { /** 插件数据目录路径(插件不存在返回 null) */
return { pluginDataDir(id: string): string | null {
get: <T>(key: string): T | undefined => { if (!this.resolved.has(id) || !this.storageService) return null
const kv = this.storage.get(pluginId) return this.storageService.dataDir(id)
const v = kv?.get(key) }
return v === undefined ? undefined : (structuredClone(v) as T)
}, /** 打开插件数据目录(资源管理器中显示;目录不存在则先建) */
set: (key: string, value: unknown): void => { openPluginDataDir(id: string): boolean {
let kv = this.storage.get(pluginId) const dir = this.pluginDataDir(id)
if (!kv) { if (!dir) return false
kv = new Map() try {
this.storage.set(pluginId, kv) mkdirSync(dir, { recursive: true })
} } catch {
kv.set(key, structuredClone(value)) // 已存在或创建失败都继续(showItemInFolder 对存在路径展示)
this.scheduleStorageSave() }
}, shell.showItemInFolder(dir)
delete: (key: string): void => { return true
this.storage.get(pluginId)?.delete(key) }
this.scheduleStorageSave()
/** 清除插件全部存储数据(连接关闭 + 删数据目录;重装后插件从零开始) */
async clearPluginData(id: string): Promise<void> {
if (!this.resolved.has(id)) throw new Error(`插件 ${id} 不存在`)
if (!this.storageService) throw new Error('存储服务未初始化')
await this.storageService.clearData(id)
log.info('plugin data cleared by user', { pluginId: id })
}
/** 必需依赖静态预检(激活前):返回错误文案,null = 全部就绪。
* 软绑定:缺失/停用/版本不符只让本插件激活失败并提示行动,不自动安装、不级联停用。 */
private checkRequiredDeps(p: ResolvedPlugin): string | null {
const deps = p.manifest.dependencies
if (!deps) return null
for (const [depId, range] of Object.entries(deps)) {
const dep = this.resolved.get(depId)
if (!dep) return `缺少依赖插件 ${depId}(${range}),请安装后重载`
if (!this.isEnabled(depId)) return `依赖插件 ${depId} 已停用,请先启用`
if (dep.error) return `依赖插件 ${depId} 装载失败,无法激活`
if (!(dep.builtinFactory ?? dep.externalFactory)) return `依赖插件 ${depId} 未提供 main 工厂`
if (!satisfiesRange(dep.manifest.version, range)) {
return `依赖插件 ${depId} 需要 ${range},当前 v${dep.manifest.version}`
} }
} }
return null
}
/** Kahn 分层拓扑排序(边 = 必需依赖 → 依赖方):依赖先激活、依赖者后激活,同波并行。
* 存在环时给环成员写明环路并返回 null(整环不激活)。 */
private topoWaves(plugins: ResolvedPlugin[]): ResolvedPlugin[][] | null {
const byId = new Map(plugins.map((p) => [p.manifest.id, p]))
const depsOf = new Map<string, string[]>()
for (const p of plugins) {
depsOf.set(
p.manifest.id,
Object.keys(p.manifest.dependencies ?? {}).filter((d) => byId.has(d))
)
}
// DFS 环检测:环成员集体报错(写明 a → b → a 环路)
const state = new Map<string, 1 | 2>()
const stack: string[] = []
let hasCycle = false
const visit = (id: string): void => {
const st = state.get(id)
if (st === 2) return
if (st === 1) {
const start = stack.indexOf(id)
const cycle = [...stack.slice(start), id].join(' → ')
for (const member of stack.slice(start)) {
const member2 = byId.get(member)
if (member2) member2.error = `循环依赖: ${cycle}`
}
hasCycle = true
return
}
state.set(id, 1)
stack.push(id)
for (const d of depsOf.get(id) ?? []) visit(d)
stack.pop()
state.set(id, 2)
}
for (const id of byId.keys()) visit(id)
if (hasCycle) return null
const indeg = new Map<string, number>()
const dependents = new Map<string, string[]>()
for (const [id, deps] of depsOf) {
indeg.set(id, deps.length)
for (const d of deps) {
const arr = dependents.get(d) ?? []
arr.push(id)
dependents.set(d, arr)
}
}
let frontier = [...indeg.entries()].filter(([, n]) => n === 0).map(([id]) => id)
const waves: ResolvedPlugin[][] = []
let remaining = plugins.length
while (frontier.length > 0) {
waves.push(frontier.map((id) => byId.get(id)!))
remaining -= frontier.length
const next: string[] = []
for (const id of frontier) {
for (const dep of dependents.get(id) ?? []) {
const n = (indeg.get(dep) ?? 0) - 1
indeg.set(dep, n)
if (n === 0) next.push(dep)
}
}
frontier = next
}
// 兜底:理论上环检测已拦;remaining > 0 说明存在未归零节点
return remaining > 0 ? null : waves
}
/** 激活单个插件(前置:其全部必需依赖已在本轮 active)。异步工厂限时兜底;失败写 error 并清理。 */
private async activatePlugin(
p: ResolvedPlugin,
pi: ExtensionAPI,
disposers: Array<() => void>,
active: Set<string>
): Promise<void> {
const id = p.manifest.id
const factory = p.builtinFactory ?? p.externalFactory
if (!factory) return
for (const depId of Object.keys(p.manifest.dependencies ?? {})) {
if (!active.has(depId)) {
p.error = `依赖插件 ${depId} 未能激活(详见该插件错误信息)`
this.clearPluginUi(id)
return
}
}
this.resetSettingsCallbacks(id)
const markActive = (d: unknown): void => {
if (typeof d === 'function') disposers.push(d as () => void)
active.add(id)
log.info('plugin activated', { pluginId: id, source: p.source })
}
try {
const result = factory(this.buildApi(p, pi))
if (result && typeof (result as Promise<unknown>).then === 'function') {
// 异步工厂:限时兜底(挂死不拖垮后续波次),reject 必须接住(不得成为 unhandled rejection)
const d = await withTimeout(result as Promise<void | (() => void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${id} 激活`)
markActive(d)
} else {
markActive(result)
}
} catch (err) {
p.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
log.error('plugin activation failed', { pluginId: id, err })
this.clearPluginUi(id)
}
} }
private scheduleStorageSave(): void { /** 挂进 AgentService 的 extensionFactories;拓扑波次激活全部已启用插件 */
if (this.storageSaveTimer) return
this.storageSaveTimer = setTimeout(() => {
this.storageSaveTimer = null
const doc: Record<string, Record<string, unknown>> = {}
for (const [pid, kv] of this.storage) doc[pid] = Object.fromEntries(kv)
try {
writeFileSync(this.storageFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
console.error('[plugins] save storage failed:', err)
}
}, 300)
}
// ---- 聚合 pi extension ----
/** 挂进 AgentService 的 extensionFactories;工厂内激活全部已启用插件 */
createAggregatedExtension(): InlineExtension { createAggregatedExtension(): InlineExtension {
return { return {
name: 'aso-plugins', name: 'aso-plugins',
factory: (pi: ExtensionAPI) => { factory: (pi: ExtensionAPI) => {
const disposers: Array<() => void> = [] const disposers: Array<() => void> = []
for (const plugin of this.resolved.values()) { this.services.clear()
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue /** 本轮激活成功集合(波次推进与依赖方前置判定的依据) */
const factory = plugin.builtinFactory ?? plugin.externalFactory const active = new Set<string>()
if (!factory) continue
try {
const result = factory(this.buildApi(plugin, pi))
if (typeof result === 'function') {
disposers.push(result)
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
void (result as Promise<void | (() => void)>).then((d) => {
if (typeof d === 'function') disposers.push(d)
})
}
} catch (err) {
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
this.clearPluginUi(plugin.manifest.id)
this.send('plugins:listChanged', null)
}
}
pi.on('session_shutdown', () => { pi.on('session_shutdown', () => {
for (const dispose of disposers) { for (const dispose of disposers) {
try { try {
dispose() dispose()
} catch (err) { } catch (err) {
console.error('[plugins] dispose error:', err) log.error('plugin dispose error', { err })
} }
} }
disposers.length = 0 disposers.length = 0
}) })
const candidates = [...this.resolved.values()].filter(
(p) => this.isEnabled(p.manifest.id) && !p.error && (p.builtinFactory ?? p.externalFactory)
)
// 静态预检:必需依赖不就绪的插件本轮直接判失败
let staticFailures = 0
for (const p of candidates) {
const failed = this.checkRequiredDeps(p)
if (failed) {
p.error = failed
staticFailures++
}
}
if (staticFailures > 0) this.send('plugins:listChanged', null)
// 拓扑波次激活;环成员已在 topoWaves 里写明 error
const waves = this.topoWaves(candidates.filter((p) => !p.error))
if (!waves || waves.length === 0) {
if (!waves) this.send('plugins:listChanged', null)
return
}
void (async () => {
for (const wave of waves) {
await Promise.all(wave.map((p) => this.activatePlugin(p, pi, disposers, active)))
}
})()
} }
} }
} }
private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) { private buildApi(plugin: ResolvedPlugin, pi: ExtensionAPI) {
const id = plugin.manifest.id const id = plugin.manifest.id
const actions = (() => {
let set = this.settingsActions.get(id)
if (!set) {
set = new Set()
this.settingsActions.set(id, set)
}
return set
})()
const listeners = (() => {
let set = this.settingsListeners.get(id)
if (!set) {
set = new Set()
this.settingsListeners.set(id, set)
}
return set
})()
if (!this.storageService) throw new Error('插件存储服务未初始化(宿主 init 未执行)')
const storage = this.storageService.forPlugin(id, (schema) => {
if (!schema || !Array.isArray(schema.tables)) return
this.storageSchemas.set(id, schema)
this.send('plugins:listChanged', null)
})
return { return {
pi, pi,
manifest: plugin.manifest, manifest: plugin.manifest,
storage: this.storageFor(id), storage,
/** 注册跨插件服务(按名键控;重载/rebuild 时随 services.clear() 清空) */
service: (name: string, impl: unknown): void => {
let svc = this.services.get(id)
if (!svc) {
svc = new Map()
this.services.set(id, svc)
}
svc.set(name, impl)
},
/** 取依赖插件的服务对象;依赖声明即访问授权——未声明直接抛错 */
requirePlugin: (targetId: string): Record<string, unknown> | undefined => {
const required = plugin.manifest.dependencies?.[targetId]
const optional = plugin.manifest.optionalDependencies?.[targetId]
if (required === undefined && optional === undefined) {
throw new Error(`插件 ${id} 未声明对 ${targetId} 的依赖(dependencies / optionalDependencies)`)
}
const svc = this.services.get(targetId)
if (!svc) {
if (optional !== undefined) return undefined
throw new Error(`依赖插件 ${targetId} 尚未就绪`)
}
return Object.fromEntries(svc)
},
log: {
debug: (message: string, kv?: Record<string, unknown>) => pluginLog.debug(message, { pluginId: id, ...kv }),
info: (message: string, kv?: Record<string, unknown>) => pluginLog.info(message, { pluginId: id, ...kv }),
warn: (message: string, kv?: Record<string, unknown>) => pluginLog.warn(message, { pluginId: id, ...kv }),
error: (message: string, kv?: Record<string, unknown>) => pluginLog.error(message, { pluginId: id, ...kv })
},
ui: { ui: {
openView: (view: string, params?: unknown) => this.openView(view, params), openView: (view: string, params?: unknown) => this.openView(view, params),
setStatusItem: (itemId: string, item: StatusItem) => this.setStatusItem(id, itemId, item), setStatusItem: (itemId: string, item: StatusItem) => this.setStatusItem(id, itemId, item),
removeStatusItem: (itemId: string) => this.setStatusItem(id, itemId, null), removeStatusItem: (itemId: string) => this.setStatusItem(id, itemId, null),
registerToolbarButton: (button: ToolbarButtonDef) => this.registerToolbarButton(id, button), registerToolbarButton: (button: ToolbarButtonDef) => this.registerToolbarButton(id, button),
removeToolbarButton: (buttonId: string) => this.removeToolbarButton(id, buttonId) removeToolbarButton: (buttonId: string) => this.removeToolbarButton(id, buttonId),
registerSettings: (schema: PluginSettingsSchema) => {
if (!schema || !Array.isArray(schema.items)) return
if (schema.items.length === 0) this.settingsSchemas.delete(id)
else this.settingsSchemas.set(id, schema)
this.send('plugins:settingsSchemas', this.settingsSchemasSnapshot())
this.send('plugins:listChanged', null)
},
getSettingValues: (): PluginSettingValues => {
const schema = this.settingsSchemas.get(id)
const stored = this.settingsValues.get(id) ?? {}
const defaults: PluginSettingValues = {}
if (schema) {
for (const item of schema.items) {
if ('default' in item && item.default !== undefined && !(item.key in stored)) defaults[item.key] = item.default
}
}
return { ...defaults, ...stored }
},
onSettingsChange: (listener: (values: PluginSettingValues) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
onSettingsAction: (listener: (key: string) => void) => {
actions.add(listener)
return () => actions.delete(listener)
}
} }
} }
} }
@@ -462,16 +967,22 @@ class PluginHost {
if (!file || !existsSync(file)) return new Response('not found', { status: 404 }) if (!file || !existsSync(file)) return new Response('not found', { status: 404 })
try { try {
const res = await net.fetch(pathToFileURL(file).toString()) const res = await net.fetch(pathToFileURL(file).toString())
// .mjs/.js 必须以 JS MIME 返回——渲染层动态 import(ESM 模块加载)强校验 content-type
const lower = file.toLowerCase()
const contentType =
lower.endsWith('.mjs') || lower.endsWith('.js')
? 'text/javascript'
: (res.headers.get('content-type') ?? 'application/octet-stream')
// no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览 // no-store:热重载换 src 即取新文件,避免缓存妨碍开发预览
return new Response(res.body, { return new Response(res.body, {
status: res.status, status: res.status,
headers: { headers: {
'content-type': res.headers.get('content-type') ?? 'application/octet-stream', 'content-type': contentType,
'cache-control': 'no-store' 'cache-control': 'no-store'
} }
}) })
} catch (err) { } catch (err) {
console.error('[plugins] serve failed:', file, err) log.error('plugin:// serve failed', { file, err })
return new Response('serve error', { status: 500 }) return new Response('serve error', { status: 500 })
} }
} }
@@ -0,0 +1,160 @@
/**
* 插件存储服务(契约 0.3.0):每插件一个 SQLite 库(userData/plugin-data/<id>/data.db)。
* node:sqlite 的 DatabaseSync 是同步 API——全部连接放在共享 worker 线程里执行
* (storage-worker.mjs,免打包纯 ESM),主进程只做异步 RPC,插件查询永不阻塞主进程。
*
* KV(kv 表)由宿主管理:api.storage.kv 的底层,value JSON 编码;
* 插件自建表经 api.storage.db 的 run/query/exec/batch 执行(batch 以事务包裹)。
*/
import { existsSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import type { PluginStorage, PluginStorageSchema } from '@aso/plugin-sdk'
import { getLogger } from '../logger'
const log = getLogger('plugin-storage')
/** 单个存储操作限时:本地 SQLite 正常毫秒级;超时视为 worker 挂死,不让插件无限等待 */
const STORAGE_OP_TIMEOUT_MS = 5_000
interface PendingCall {
resolve: (value: unknown) => void
reject: (err: Error) => void
}
export class PluginStorageService {
private worker: Worker | null = null
private pending = new Map<number, PendingCall>()
private reqSeq = 0
constructor(private dataRoot: string) {}
/** worker 文件与主进程产物同目录(构建期 copyStorageWorker 拷贝到 out/main/) */
private workerPath(): string {
return join(dirname(fileURLToPath(import.meta.url)), 'storage-worker.mjs')
}
private ensureWorker(): Worker {
if (this.worker) return this.worker
const file = this.workerPath()
if (!existsSync(file)) throw new Error(`存储 worker 缺失: ${file}`)
const w = new Worker(file)
w.on('message', (msg: { reqId: number; ok: boolean; result?: unknown; error?: string }) => {
const call = this.pending.get(msg.reqId)
if (!call) return
this.pending.delete(msg.reqId)
if (msg.ok) call.resolve(msg.result)
else call.reject(new Error(msg.error ?? '存储操作失败'))
})
const failAll = (err: Error): void => {
for (const call of this.pending.values()) call.reject(err)
this.pending.clear()
this.worker = null
}
w.on('error', (err: Error) => {
log.error('storage worker error', { err: err.message })
failAll(new Error(`存储 worker 崩溃: ${err.message}`))
})
w.on('exit', (code) => {
if (code !== 0) log.error('storage worker exited', { code })
failAll(new Error(`存储 worker 已退出(code=${code})`))
})
// dataRoot 经 init 下发(worker 不依赖 electron app 路径)
this.worker = w
this.rawCall(w, 'init', { dataRoot: this.dataRoot }).catch((err) => log.error('storage worker init failed', { err }))
return w
}
/** 不经 pending 表的内部调用(init 用) */
private rawCall(w: Worker, op: string, payload: Record<string, unknown>): Promise<unknown> {
return new Promise((resolve, reject) => {
const reqId = ++this.reqSeq
const onMessage = (msg: { reqId: number; ok: boolean; result?: unknown; error?: string }): void => {
if (msg.reqId !== reqId) return
w.off('message', onMessage)
if (msg.ok) resolve(msg.result)
else reject(new Error(msg.error ?? '存储操作失败'))
}
w.on('message', onMessage)
w.postMessage({ reqId, op, ...payload })
})
}
private call<T>(op: string, payload: Record<string, unknown>): Promise<T> {
const w = this.ensureWorker()
const reqId = ++this.reqSeq
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending.delete(reqId)) reject(new Error(`存储操作超时(${op},${STORAGE_OP_TIMEOUT_MS}ms)`))
}, STORAGE_OP_TIMEOUT_MS)
this.pending.set(reqId, {
resolve: (v) => {
clearTimeout(timer)
resolve(v as T)
},
reject: (e) => {
clearTimeout(timer)
reject(e)
}
})
w.postMessage({ reqId, op, ...payload })
})
}
// ---- 宿主侧管理 ----
/** 插件数据目录(id 已按 manifest 规则校验,可安全作路径段) */
dataDir(pluginId: string): string {
return join(this.dataRoot, pluginId)
}
/** 关闭某插件的连接(清除数据前调用;一般重载无需关闭——连接跨重建复用) */
async closePlugin(pluginId: string): Promise<void> {
if (!this.worker) return
try {
await this.call('close', { pluginId })
} catch (err) {
log.warn('close plugin db failed', { pluginId, err: err instanceof Error ? err.message : String(err) })
}
}
/** 清除插件全部数据:关闭连接 + 删整个数据目录(设置页「清除数据」显式动作) */
async clearData(pluginId: string): Promise<void> {
await this.closePlugin(pluginId)
await rm(this.dataDir(pluginId), { recursive: true, force: true })
log.info('plugin data cleared', { pluginId })
}
/** 应用退出时收尾(WAL 已逐语句持久化,失败也不阻塞退出) */
dispose(): void {
const w = this.worker
this.worker = null
for (const call of this.pending.values()) call.reject(new Error('存储服务已关闭'))
this.pending.clear()
void w?.terminate()
}
// ---- 插件 API(api.storage) ----
/** 组装某插件的存储 API;onSchema 由宿主提供(收集 declareSchema 声明) */
forPlugin(pluginId: string, onSchema: (schema: PluginStorageSchema) => void): PluginStorage {
return {
kv: {
get: (key) => this.call<unknown>('kvGet', { pluginId, key }).then((v) => (v === null ? undefined : (v as never))),
set: (key, value) => this.call('kvSet', { pluginId, key, value: value ?? null }),
delete: (key) => this.call('kvDelete', { pluginId, key }),
keys: () => this.call<string[]>('kvKeys', { pluginId })
},
db: {
exec: (sql) => this.call('exec', { pluginId, sql }),
run: (sql, params) => this.call('run', { pluginId, sql, params: params ?? [] }),
query: (sql, params) => this.call<Record<string, unknown>[]>('query', { pluginId, sql, params: params ?? [] }),
batch: (statements) => this.call('batch', { pluginId, statements })
},
dir: () => this.dataDir(pluginId),
declareSchema: (schema) => onSchema(schema)
}
}
}
@@ -0,0 +1,102 @@
/**
* 插件存储 worker(免打包纯 ESM,只依赖 node 内置模块;构建期拷到 out/main/ 旁)。
* 持有全部插件的 SQLite 连接(node:sqlite DatabaseSync),按消息逐个执行——
* 同步 API 全部留在本线程,主进程只做异步 RPC,插件查询永不阻塞主进程事件循环。
*
* 协议:主进程 → { reqId, op, ...payload };worker → { reqId, ok, result? | error? }。
* 首条消息必须是 init(下发 dataRoot,避免 worker 依赖 electron app 路径)。
*/
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { parentPort } from 'node:worker_threads'
/** dataRoot(userData/plugin-data);由 init 消息下发 */
let dataRoot = null
/** pluginId → 已打开连接(跨消息复用;close/clearData 时移除) */
const dbs = new Map()
function openDb(pluginId) {
let db = dbs.get(pluginId)
if (db) return db
// pluginId 已由宿主按 manifest 规则校验(小写字母开头,字母/数字/连字符),可安全作路径段
mkdirSync(join(dataRoot, pluginId), { recursive: true })
db = new DatabaseSync(join(dataRoot, pluginId, 'data.db'))
db.exec('PRAGMA journal_mode = WAL;')
// 宿主管理的 KV 表:api.storage.kv 的底层(value JSON 编码)
db.exec('CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
dbs.set(pluginId, db)
return db
}
function handle(op, msg) {
switch (op) {
case 'init':
dataRoot = msg.dataRoot
return null
case 'close': {
const db = dbs.get(msg.pluginId)
if (db) {
dbs.delete(msg.pluginId)
db.close()
}
return null
}
case 'kvGet': {
const row = openDb(msg.pluginId).prepare('SELECT value FROM kv WHERE key = ?').get(msg.key)
return row ? JSON.parse(row.value) : null
}
case 'kvSet':
openDb(msg.pluginId)
.prepare('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(msg.key, JSON.stringify(msg.value ?? null))
return null
case 'kvDelete':
openDb(msg.pluginId).prepare('DELETE FROM kv WHERE key = ?').run(msg.key)
return null
case 'kvKeys':
return openDb(msg.pluginId)
.prepare('SELECT key FROM kv ORDER BY key')
.all()
.map((r) => r.key)
case 'exec':
openDb(msg.pluginId).exec(msg.sql)
return null
case 'run':
openDb(msg.pluginId).prepare(msg.sql).run(...(msg.params ?? []))
return null
case 'query':
return openDb(msg.pluginId).prepare(msg.sql).all(...(msg.params ?? []))
case 'batch': {
const db = openDb(msg.pluginId)
db.exec('BEGIN')
try {
for (const st of msg.statements ?? []) db.prepare(st.sql).run(...(st.params ?? []))
db.exec('COMMIT')
} catch (err) {
try {
db.exec('ROLLBACK')
} catch {
// 连接已坏时 ROLLBACK 自身会失败——原始错误优先
}
throw err
}
return null
}
default:
throw new Error(`未知存储操作: ${op}`)
}
}
parentPort.on('message', (msg) => {
if (!dataRoot && msg.op !== 'init') {
parentPort.postMessage({ reqId: msg.reqId, ok: false, error: '存储 worker 未初始化' })
return
}
try {
parentPort.postMessage({ reqId: msg.reqId, ok: true, result: handle(msg.op, msg) ?? null })
} catch (err) {
parentPort.postMessage({ reqId: msg.reqId, ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
+45 -3
View File
@@ -8,7 +8,10 @@ import { getBrowserController } from '../browser/browser-controller'
* 使用范式(与 Playwright MCP / Proma 一致的 AX 快照 + ref 体系): * 使用范式(与 Playwright MCP / Proma 一致的 AX 快照 + ref 体系):
* 1. browser_observe / browser_find 拿到带 ref 的可交互元素快照; * 1. browser_observe / browser_find 拿到带 ref 的可交互元素快照;
* 2. 用 ref 调 click / fill / act 等操作;页面变化后旧 ref 失效,必须重新观察; * 2. 用 ref 调 click / fill / act 等操作;页面变化后旧 ref 失效,必须重新观察;
* 3. 语义结构足够时优先 observe,需要视觉验证时才 browser_screenshot。 * 3. 语义结构足够时优先 observe,需要视觉验证时才 browser_screenshot;
* 4. 目标不在 AX 快照里(纯 div/span + JS 点击的元素,如商品规格/容量按钮)时,
* 用 browser_find_text 按可见文本或 CSS 选择器在 DOM 中定位,拿到 ref 后照常操作;
* 5. 一切定位手段都失效(canvas、封闭 shadow DOM)才 browser_screenshot + browser_click_point。
*/ */
const TAB_ID_PARAM = Type.Optional( const TAB_ID_PARAM = Type.Optional(
@@ -72,12 +75,34 @@ export const browserTools = [
} }
}), }),
defineTool({
name: 'browser_find_text',
label: '按文本定位元素',
description:
'按可见文本或 CSS 选择器在页面 DOM 中定位元素并返回新 ref(text 与 selector 二选一)。不依赖无障碍树,专治 observe/find 快照里缺失的纯 div/span 可点击元素(商品规格、容量按钮、自定义控件)。text 匹配元素自身的直接文本(空白归一后精确匹配;contains=true 改为包含)。多个命中时不发 ref,返回候选列表(含 rect/上下文),带 index 重调以选定。命中并注册 ref 时作废旧 ref。支持开放 shadow DOM 与同源 iframe(iframe 内元素只能看候选,点击需改用坐标)。注意:快照配额会挤掉普通文本节点,即使 observe 看似完整,找不到目标时也应优先改用本工具。',
promptSnippet: 'browser_find_text: 按文本/选择器定位(AX 快照缺失时用)',
parameters: Type.Object({
text: Type.Optional(Type.String({ description: '元素自身可见文本,如 "256GB"' })),
selector: Type.Optional(Type.String({ description: 'CSS 选择器(文本重复或目标无文本时用)' })),
contains: Type.Optional(Type.Boolean({ description: '文本包含匹配(默认精确匹配)' })),
index: Type.Optional(Type.Number({ minimum: 1, description: '多个候选时指定第几个(1 起,来自上次返回的候选列表)' })),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const result = await getBrowserController().findText(
{ text: params.text, selector: params.selector, contains: params.contains, index: params.index },
params.tabId
)
return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result }
}
}),
defineTool({ defineTool({
name: 'browser_click', name: 'browser_click',
label: '点击元素', label: '点击元素',
description: '点击最新 observe/find 结果中的指定 ref。页面会短暂高亮目标。点击导致的重渲染会让旧 ref 失效,操作后需重新观察。', description: '点击最新 observe/find/find_text 结果中的指定 ref。页面会短暂高亮目标。点击导致的重渲染会让旧 ref 失效,操作后需重新观察。',
promptSnippet: 'browser_click: 点击 ref', promptSnippet: 'browser_click: 点击 ref',
parameters: Type.Object({ ref: Type.String({ description: '元素引用(来自 observe/find)' }), tabId: TAB_ID_PARAM }), parameters: Type.Object({ ref: Type.String({ description: '元素引用(来自 observe/find/find_text)' }), tabId: TAB_ID_PARAM }),
async execute(_toolCallId, params) { async execute(_toolCallId, params) {
const state = await getBrowserController().click(params.ref, params.tabId) const state = await getBrowserController().click(params.ref, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ clicked: params.ref, url: state.tabs.find((t) => t.tabId === state.activeTabId)?.url }) }], details: state } return { content: [{ type: 'text', text: JSON.stringify({ clicked: params.ref, url: state.tabs.find((t) => t.tabId === state.activeTabId)?.url }) }], details: state }
@@ -107,6 +132,23 @@ export const browserTools = [
} }
}), }),
defineTool({
name: 'browser_click_point',
label: '坐标点击(兜底)',
description:
'点击最近一次 browser_screenshot 图像上的像素坐标 (x, y)。仅当 observe/find/find_text 都无法定位目标时的最后手段(canvas、封闭 shadow DOM、混淆页面)。点击前必须先截最新图;坐标越界会直接报错。',
promptSnippet: 'browser_click_point: 截图坐标兜底点击(最后手段)',
parameters: Type.Object({
x: Type.Number({ minimum: 0, description: '截图图像上的 x 像素' }),
y: Type.Number({ minimum: 0, description: '截图图像上的 y 像素' }),
tabId: TAB_ID_PARAM
}),
async execute(_toolCallId, params) {
const state = await getBrowserController().clickPoint(params.x, params.y, params.tabId)
return { content: [{ type: 'text', text: JSON.stringify({ clicked: [params.x, params.y], url: state.tabs.find((t) => t.tabId === state.activeTabId)?.url }) }], details: state }
}
}),
defineTool({ defineTool({
name: 'browser_fill', name: 'browser_fill',
label: '填写输入框', label: '填写输入框',
+37 -5
View File
@@ -3,8 +3,7 @@ import type {
AgentUiEvent, AgentUiEvent,
AppSettings, AppSettings,
AttachmentInfo, AttachmentInfo,
BrowserImportResult, BrowserProxySettings,
BrowserSource,
BrowserUiEvent, BrowserUiEvent,
BrowserUiState, BrowserUiState,
ChatResources, ChatResources,
@@ -25,6 +24,7 @@ import type {
WorkspaceFile, WorkspaceFile,
WorkspaceMeta WorkspaceMeta
} from '@shared/types' } from '@shared/types'
import type { PluginSettingsSchemas, PluginSettingsValuesMap } from '@aso/plugin-sdk'
const api = { const api = {
// 窗口控制(自绘标题栏按钮) // 窗口控制(自绘标题栏按钮)
@@ -96,6 +96,8 @@ const api = {
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'), getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id), setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
setThemeMode: (mode: ThemeMode): Promise<void> => ipcRenderer.invoke('settings:setThemeMode', mode), setThemeMode: (mode: ThemeMode): Promise<void> => ipcRenderer.invoke('settings:setThemeMode', mode),
setResourceEnabled: (kind: 'skill' | 'command', name: string, enabled: boolean): Promise<void> =>
ipcRenderer.invoke('settings:setResourceEnabled', kind, name, enabled),
resolveApproval: (id: string, approved: boolean): Promise<void> => resolveApproval: (id: string, approved: boolean): Promise<void> =>
ipcRenderer.invoke('approval:resolve', id, approved), ipcRenderer.invoke('approval:resolve', id, approved),
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => { onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
@@ -123,9 +125,9 @@ const api = {
revision: number revision: number
bounds: { x: number; y: number; width: number; height: number } bounds: { x: number; y: number; width: number; height: number }
}): Promise<void> => ipcRenderer.invoke('browser:layout', layout), }): Promise<void> => ipcRenderer.invoke('browser:layout', layout),
browserDetectSources: (): Promise<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'), browserGetProxy: (): Promise<BrowserProxySettings> => ipcRenderer.invoke('browser:getProxy'),
browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> => browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> =>
ipcRenderer.invoke('browser:importProfile', sourceId, profileDir), ipcRenderer.invoke('browser:setProxy', input),
onBrowserEvent: (callback: (event: BrowserUiEvent) => void): (() => void) => { onBrowserEvent: (callback: (event: BrowserUiEvent) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, event: BrowserUiEvent): void => callback(event) const listener = (_e: Electron.IpcRendererEvent, event: BrowserUiEvent): void => callback(event)
ipcRenderer.on('browser:event', listener) ipcRenderer.on('browser:event', listener)
@@ -137,8 +139,38 @@ const api = {
// ---- 插件系统 ---- // ---- 插件系统 ----
pluginsList: (): Promise<PluginInfo[]> => ipcRenderer.invoke('plugins:list'), pluginsList: (): Promise<PluginInfo[]> => ipcRenderer.invoke('plugins:list'),
pluginsUiState: (): Promise<PluginsUiState> => ipcRenderer.invoke('plugins:uiState'), pluginsUiState: (): Promise<PluginsUiState> => ipcRenderer.invoke('plugins:uiState'),
pluginsDirs: (): Promise<{ user: string[]; project: string[]; global: string[] }> =>
ipcRenderer.invoke('plugins:dirs'),
pluginsReadRenderer: (id: string): Promise<string> => ipcRenderer.invoke('plugins:readRenderer', id),
pluginsSettingsSchemas: (): Promise<PluginSettingsSchemas> => ipcRenderer.invoke('plugins:settingsSchemas'),
pluginsSettingsValues: (): Promise<PluginSettingsValuesMap> => ipcRenderer.invoke('plugins:settingsValues'),
pluginsSetSettingValue: (id: string, key: string, value: unknown): Promise<void> =>
ipcRenderer.invoke('plugins:setSettingValue', id, key, value),
pluginsSetSettingsNav: (id: string, on: boolean): Promise<void> =>
ipcRenderer.invoke('plugins:setSettingsNav', id, on),
pluginsSettingsAction: (id: string, key: string): Promise<void> =>
ipcRenderer.invoke('plugins:settingsAction', id, key),
pluginsPickPath: (mode: 'file' | 'directory' | 'image'): Promise<string | null> =>
ipcRenderer.invoke('plugins:pickPath', mode),
pluginsReadImage: (path: string): Promise<string | null> => ipcRenderer.invoke('plugins:readImage', path),
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, schemas: PluginSettingsSchemas): void => callback(schemas)
ipcRenderer.on('plugins:settingsSchemas', listener)
return () => {
ipcRenderer.removeListener('plugins:settingsSchemas', listener)
}
},
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void): (() => void) => {
const listener = (_e: Electron.IpcRendererEvent, values: PluginSettingsValuesMap): void => callback(values)
ipcRenderer.on('plugins:settingsValues', listener)
return () => {
ipcRenderer.removeListener('plugins:settingsValues', listener)
}
},
pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> => pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> =>
ipcRenderer.invoke('plugins:setEnabled', id, enabled), ipcRenderer.invoke('plugins:setEnabled', id, enabled),
pluginsOpenDataDir: (id: string): Promise<boolean> => ipcRenderer.invoke('plugins:openDataDir', id),
pluginsClearData: (id: string): Promise<void> => ipcRenderer.invoke('plugins:clearData', id),
pluginsReload: (): Promise<void> => ipcRenderer.invoke('plugins:reload'), pluginsReload: (): Promise<void> => ipcRenderer.invoke('plugins:reload'),
pluginsOpenView: (view: string, params?: unknown): Promise<void> => pluginsOpenView: (view: string, params?: unknown): Promise<void> =>
ipcRenderer.invoke('plugins:openView', view, params), ipcRenderer.invoke('plugins:openView', view, params),
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' plugin: blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'self' plugin:"
/> />
<title>Inkreach Agent</title> <title>Inkreach Agent</title>
</head> </head>
+76 -13
View File
@@ -12,9 +12,17 @@ import { SettingsModal } from './components/SettingsModal'
import { Search } from './components/Search' import { Search } from './components/Search'
import { BrowserPanel } from './components/browser/BrowserPanel' import { BrowserPanel } from './components/browser/BrowserPanel'
import { SidePanel, type PanelTabIcon } from './components/SidePanel' import { SidePanel, type PanelTabIcon } from './components/SidePanel'
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame' import { PluginPageFrame } from './plugins/PluginPageFrame'
import { PluginIconValue } from './plugins/plugin-icons'
import {
useExternalPluginComponents,
usePluginRendererLoader,
usePluginRenderStatus,
retryPluginRenderer
} from './plugins/renderer-host'
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary' import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules' import { PluginFloatingCapsule } from './plugins/PluginFloatingCapsule'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store' import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components' import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
import { applyThemeColor, watchColorScheme } from './lib/theme' import { applyThemeColor, watchColorScheme } from './lib/theme'
@@ -59,6 +67,20 @@ function loadSideState(): SideState {
return { open: false, width: SIDE_DEFAULT_WIDTH, tabs: [], active: null } return { open: false, width: SIDE_DEFAULT_WIDTH, tabs: [], active: null }
} }
/** 外置 renderer 槽位的统一等待/失败态:装载中显示 LoadingState,失败显示 ErrorState + 重试 */
function PluginRendererSlot({ pluginId }: { pluginId: string }): JSX.Element {
const status = usePluginRenderStatus(pluginId)
if (!status || status.state !== 'error') return <LoadingState className="h-full" />
return (
<ErrorState
className="h-full"
title="插件界面装载失败"
description={status.message}
onRetry={() => retryPluginRenderer(pluginId)}
/>
)
}
export default function App(): JSX.Element { export default function App(): JSX.Element {
const { const {
state, state,
@@ -99,6 +121,7 @@ export default function App(): JSX.Element {
// ---- 插件系统:视图切换('chat' | 插件 id)+ 深链参数 ---- // ---- 插件系统:视图切换('chat' | 插件 id)+ 深链参数 ----
const plugins = usePluginList() const plugins = usePluginList()
const pluginsUi = usePluginsUiState() const pluginsUi = usePluginsUiState()
const externalComponents = useExternalPluginComponents()
const [activeView, setActiveView] = useState<{ view: string; params?: unknown }>({ view: 'chat' }) const [activeView, setActiveView] = useState<{ view: string; params?: unknown }>({ view: 'chat' })
const activeViewRef = useRef(activeView) const activeViewRef = useRef(activeView)
activeViewRef.current = activeView activeViewRef.current = activeView
@@ -107,11 +130,13 @@ export default function App(): JSX.Element {
pluginByIdRef.current = pluginById pluginByIdRef.current = pluginById
const entryPlugins = useMemo(() => sidebarPlugins(plugins), [plugins]) const entryPlugins = useMemo(() => sidebarPlugins(plugins), [plugins])
const panelPlugins = useMemo( const panelPlugins = useMemo(
() => plugins.filter((p) => p.enabled && p.manifest.panel && p.hasPage), () => plugins.filter((p) => p.enabled && p.manifest.panel && (p.hasPage || p.hasRenderer)),
[plugins] [plugins]
) )
/** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */ /** 热重载令牌:变化即让插件页 iframe 换 src 整页重载(手动重载/开发期 watch 触发) */
const [pluginReloadToken, setPluginReloadToken] = useState(0) const [pluginReloadToken, setPluginReloadToken] = useState(0)
// 外置插件 renderer 模块装载(entry.renderer → 宿主内 React 组件;仅开发版)
usePluginRendererLoader(plugins, pluginReloadToken)
/** 打开视图('chat' 或插件 id,携深链参数);插件声明 panel 时联动展开其面板标签 */ /** 打开视图('chat' 或插件 id,携深链参数);插件声明 panel 时联动展开其面板标签 */
const handleOpenView = useCallback((view: string, params?: unknown) => { const handleOpenView = useCallback((view: string, params?: unknown) => {
@@ -120,7 +145,7 @@ export default function App(): JSX.Element {
return return
} }
const plugin = pluginByIdRef.current.get(view) const plugin = pluginByIdRef.current.get(view)
if (!plugin || !plugin.enabled || (!plugin.hasComponent && !plugin.hasPage)) return if (!plugin || !plugin.enabled || (!plugin.hasComponent && !plugin.hasPage && !plugin.hasRenderer)) return
setActiveView({ view, params }) setActiveView({ view, params })
if (plugin.manifest.panel) { if (plugin.manifest.panel) {
setSide((s) => ({ setSide((s) => ({
@@ -292,9 +317,9 @@ export default function App(): JSX.Element {
const pluginTabIcons = useMemo(() => { const pluginTabIcons = useMemo(() => {
const map = new Map<string, PanelTabIcon>() const map = new Map<string, PanelTabIcon>()
for (const p of panelPlugins) { for (const p of panelPlugins) {
const svg = p.manifest.icon const icon = p.manifest.icon
const Icon = ({ size, className }: { size?: number; className?: string }): JSX.Element => ( const Icon = ({ size, className }: { size?: number; className?: string }): JSX.Element => (
<PluginIcon svg={svg} size={size ?? 16} className={className} /> <PluginIconValue icon={icon} size={size ?? 16} className={className} />
) )
map.set(p.manifest.id, Icon) map.set(p.manifest.id, Icon)
} }
@@ -304,6 +329,32 @@ export default function App(): JSX.Element {
// 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失) // 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失)
const browserEnabled = plugins.find((p) => p.manifest.id === 'browser')?.enabled ?? true const browserEnabled = plugins.find((p) => p.manifest.id === 'browser')?.enabled ?? true
// Agent 调用浏览器工具 → 自动展开侧面板并激活浏览器标签。
// 每回合(agent_start)只强制一次:回合中用户手动收起面板不被反复顶开。
const browserAutoOpenedRef = useRef(false)
useEffect(() => {
return window.api.onEvent((event) => {
if (event.type === 'agent_start') {
browserAutoOpenedRef.current = false
return
}
if (
event.type === 'tool_start' &&
event.toolName.startsWith('browser_') &&
!browserAutoOpenedRef.current &&
browserEnabled
) {
browserAutoOpenedRef.current = true
setSide((s) => ({
...s,
open: true,
tabs: s.tabs.includes('browser') ? s.tabs : [...s.tabs, 'browser'],
active: 'browser'
}))
}
})
}, [browserEnabled])
// 面板标签:browser(宿主内置组件)+ manifest 声明 panel 的外置插件(iframe 页) // 面板标签:browser(宿主内置组件)+ manifest 声明 panel 的外置插件(iframe 页)
const panelTabs = sideTabIds.flatMap((id) => { const panelTabs = sideTabIds.flatMap((id) => {
if (id === 'browser') { if (id === 'browser') {
@@ -326,8 +377,6 @@ export default function App(): JSX.Element {
onCloseTab={browser.closeTab} onCloseTab={browser.closeTab}
onCloseAllTabs={browser.closeBrowser} onCloseAllTabs={browser.closeBrowser}
onAcknowledgeRisk={browser.acknowledgeRisk} onAcknowledgeRisk={browser.acknowledgeRisk}
onDetectSources={browser.detectSources}
onImportProfile={browser.importProfile}
/> />
) )
} }
@@ -335,13 +384,20 @@ export default function App(): JSX.Element {
} }
const plugin = pluginById.get(id) const plugin = pluginById.get(id)
if (!plugin || !plugin.enabled || !plugin.manifest.panel) return [] if (!plugin || !plugin.enabled || !plugin.manifest.panel) return []
const PanelComponent = plugin.hasRenderer ? externalComponents.get(id)?.panel : undefined
return [ return [
{ {
id, id,
title: plugin.manifest.name, title: plugin.manifest.name,
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon), icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
content: ( content: PanelComponent ? (
<PanelComponent />
) : plugin.hasRenderer ? (
<PluginRendererSlot pluginId={id} />
) : plugin.hasPage ? (
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} /> <PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
) : (
<div className="flex h-full items-center justify-center text-[12px] text-ink-3">插件未提供页面</div>
) )
} }
] ]
@@ -355,11 +411,13 @@ export default function App(): JSX.Element {
})) }))
] ]
// 当前插件视图(非 chat 时渲染其 Main:内置组件 or 外置 iframe 页) // 当前插件视图(非 chat 时渲染其 Main:内置组件 / 外置 renderer 注册组件 / 外置 iframe 页)
const activePlugin: PluginInfo | undefined = const activePlugin: PluginInfo | undefined =
activeView.view === 'chat' ? undefined : pluginById.get(activeView.view) activeView.view === 'chat' ? undefined : pluginById.get(activeView.view)
const ActivePluginComponent: ComponentType | undefined = activePlugin?.hasComponent const ActivePluginComponent: ComponentType | undefined = activePlugin
? BUILTIN_PLUGIN_COMPONENTS[activeView.view] ? activePlugin.hasComponent
? BUILTIN_PLUGIN_COMPONENTS[activeView.view]
: externalComponents.get(activeView.view)?.view
: undefined : undefined
return ( return (
@@ -390,8 +448,11 @@ export default function App(): JSX.Element {
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name} title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
panelOpen={sideOpen} panelOpen={sideOpen}
onTogglePanel={toggleSide} onTogglePanel={toggleSide}
extra={<PluginStatusCapsules entries={pluginsUi.status} onOpenView={handleOpenView} />}
/> />
{/* 悬浮状态胶囊:main 区右上角(侧面板打开时左移让位) */}
<div className="absolute top-12 z-40" style={{ right: sideOpen ? sideWidth + 12 : 12 }}>
<PluginFloatingCapsule entries={pluginsUi.status} plugins={plugins} onOpenView={handleOpenView} />
</div>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */} {/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
<div <div
@@ -466,6 +527,8 @@ export default function App(): JSX.Element {
> >
{ActivePluginComponent ? ( {ActivePluginComponent ? (
<ActivePluginComponent /> <ActivePluginComponent />
) : activePlugin.hasRenderer ? (
<PluginRendererSlot pluginId={activeView.view} />
) : activePlugin.hasPage ? ( ) : activePlugin.hasPage ? (
<PluginPageFrame <PluginPageFrame
plugin={activePlugin} plugin={activePlugin}
@@ -108,11 +108,13 @@ function renderTurns(
// turnActive/streaming 是会话级信号:只有延伸到消息列表末尾的最后一个 // turnActive/streaming 是会话级信号:只有延伸到消息列表末尾的最后一个
// assistant 组是「活跃回合」。透传给历史回合会让它们误显示 // assistant 组是「活跃回合」。透传给历史回合会让它们误显示
// 「已工作/处理中」并保持过程区展开。 // 「已工作/处理中」并保持过程区展开。
// 放宽:会话级 turnActive 期间,组后仅尾随非真实用户消息(自动压缩/分支 // 放宽边界:会话级 turnActive 期间,组后尾随「非用户消息」(自动压缩/分支
// 摘要通知行)时不算离开末尾——否则压缩间隙 live 跌落,600ms 自动收起后 // 摘要通知行等)时不算离开末尾——否则压缩间隙 live 跌落,600ms 自动收起后
// 续跑又展开,形成展开/收起反复横跳(慢工具/长会话反复压缩尤甚)。 // 续跑又展开,形成展开/收起反复横跳(慢工具/长会话反复压缩尤甚)。
// 任何 user 消息(含本地乐观插入)都是硬边界——否则本地用户消息尾随时,
// 前一个历史回合会被误激活展开(实测捕获:h- 前缀历史回合 false->true)。
let liveEnd = j let liveEnd = j
while (liveEnd < messages.length && !(messages[liveEnd].role === 'user' && !messages[liveEnd].local)) { while (liveEnd < messages.length && messages[liveEnd].role !== 'user') {
liveEnd += 1 liveEnd += 1
} }
const isLive = j === messages.length || (turnActive && liveEnd === messages.length) const isLive = j === messages.length || (turnActive && liveEnd === messages.length)
@@ -7,7 +7,7 @@ import { StreamingText } from './StreamingText'
import { Thinking, thinkingRows } from './Thinking' import { Thinking, thinkingRows } from './Thinking'
import { ToolChips, type ToolCallView } from './ToolChips' import { ToolChips, type ToolCallView } from './ToolChips'
import { useEntrance } from '../hooks/useEntrance' import { useEntrance } from '../hooks/useEntrance'
import { LuFoldVertical, LuGitFork, LuHistory, LuLoaderCircle, LuUndo2 } from 'react-icons/lu' import { LuFoldVertical, LuGitFork, LuHistory, LuLoaderCircle, LuTriangleAlert, LuUndo2 } from 'react-icons/lu'
import { formatTokens } from '../lib/utils' import { formatTokens } from '../lib/utils'
interface ContentBlock { interface ContentBlock {
@@ -41,6 +41,29 @@ function lastThinkingLine(text: string): string {
return '' return ''
} }
/** 消息级错误(stopReason:'error',如 API 402 余额耗尽/限流):无此错误返回空串 */
function readMessageError(raw: unknown): string {
if (!raw || typeof raw !== 'object') return ''
const m = raw as { stopReason?: unknown; errorMessage?: unknown }
if (m.stopReason !== 'error' || typeof m.errorMessage !== 'string' || !m.errorMessage.trim()) return ''
return m.errorMessage
}
/** 提炼错误文案的可读部分:'402: {"message":"Insufficient Balance",…}' → '402: Insufficient Balance' */
function readableMessageError(err: string): string {
const match = err.match(/^(\d{3}):\s*([\s\S]*)$/)
const body = match ? match[2] : err
try {
const parsed = JSON.parse(body) as { message?: unknown }
if (parsed && typeof parsed.message === 'string' && parsed.message) {
return match ? `${match[1]}: ${parsed.message}` : parsed.message
}
} catch {
// 非 JSON 体,保持原文
}
return err
}
/** 工作计时:working=true 起表,结束后冻结读数。 /** 工作计时:working=true 起表,结束后冻结读数。
* working 中途抖动(pi 轮次间隙 streaming/turnActive 抖动)不锁存: * working 中途抖动(pi 轮次间隙 streaming/turnActive 抖动)不锁存:
* 恢复 true 时解除冻结,继续从起点计时。 */ * 恢复 true 时解除冻结,继续从起点计时。 */
@@ -166,6 +189,8 @@ function AssistantTurnBase({
let thinking = 0 let thinking = 0
let stream: UiMessage['stream'] | null = null let stream: UiMessage['stream'] | null = null
let answerText = '' let answerText = ''
// 消息级错误(API 失败等):回合内最后一条错误保留展示,否则错误轮会渲染成空白
let messageError = ''
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。 // 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
// 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。 // 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
@@ -177,6 +202,8 @@ function AssistantTurnBase({
stream = message.stream stream = message.stream
continue continue
} }
const msgErr = readMessageError(message.raw)
if (msgErr) messageError = msgErr
const asAnswer = settled && streamPos === -1 && idx === messages.length - 1 const asAnswer = settled && streamPos === -1 && idx === messages.length - 1
for (const block of readBlocks(message.raw)) { for (const block of readBlocks(message.raw)) {
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
@@ -225,7 +252,8 @@ function AssistantTurnBase({
// live 回合从一开始就可见,挂载内容;一旦挂载过就保持(再次折叠走 GSAP // live 回合从一开始就可见,挂载内容;一旦挂载过就保持(再次折叠走 GSAP
// 高度动画需要内容在场,且 Thinking 面板内部滚动状态不重置)。 // 高度动画需要内容在场,且 Thinking 面板内部滚动状态不重置)。
const [contentMounted, setContentMounted] = useState<boolean>(() => live) const [contentMounted, setContentMounted] = useState<boolean>(() => live)
const autoCollapsedRef = useRef(false) // 用户手动收起过的回合不再自动展开;自动收起(结算/技术性重挂载)不受此限
const userCollapsedRef = useRef(false)
const prevLiveRef = useRef(live) const prevLiveRef = useRef(live)
useEffect(() => { useEffect(() => {
const prev = prevLiveRef.current const prev = prevLiveRef.current
@@ -233,14 +261,14 @@ function AssistantTurnBase({
if (prev && !live) { if (prev && !live) {
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
if (scrollPinnedRef?.current !== false) { if (scrollPinnedRef?.current !== false) {
autoCollapsedRef.current = true
setCollapsed(true) setCollapsed(true)
} }
}, 600) }, 600)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
if (!prev && live && autoCollapsedRef.current) { // live 恢复时重新展开(含重挂载场景:初始收起的回合在回合续跑后自愈展开,
autoCollapsedRef.current = false // 不依赖随重挂载丢失的 ref 状态);用户手动收起过的除外
if (!prev && live && !userCollapsedRef.current) {
setContentMounted(true) setContentMounted(true)
setCollapsed(false) setCollapsed(false)
} }
@@ -367,14 +395,17 @@ function AssistantTurnBase({
if (answerText.trim() && !hasAnswer) setHasAnswer(true) if (answerText.trim() && !hasAnswer) setHasAnswer(true)
return ( return (
<div className="min-w-0 max-w-full"> <div
className="min-w-0 max-w-full"
style={{ animation: 'fade-up 400ms cubic-bezier(0.23, 1, 0.32, 1) both' }}
>
{hasProcess && ( {hasProcess && (
<div className="border-b border-line pb-1"> <div className="border-b border-line pb-1">
<button <button
type="button" type="button"
aria-expanded={!collapsed} aria-expanded={!collapsed}
onClick={() => { onClick={() => {
autoCollapsedRef.current = false userCollapsedRef.current = true
setContentMounted(true) setContentMounted(true)
setCollapsed((v) => !v) setCollapsed((v) => !v)
}} }}
@@ -436,6 +467,19 @@ function AssistantTurnBase({
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */} {/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
{turnActive && !stream && <Loading label="处理中" variant="Dots" />} {turnActive && !stream && <Loading label="处理中" variant="Dots" />}
{/* 消息级错误(stopReason:'error',如 API 402 余额耗尽 / 限流):常驻可见,
不随过程区折叠——否则失败轮在界面上就是一片空白,无从排查 */}
{messageError && (
<div className="mt-2 flex items-start gap-2.5 rounded-control border border-red/40 bg-red-tint px-3.5 py-2.5 text-[12.5px] text-red">
<LuTriangleAlert size={14} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="mb-0.5 font-medium">模型请求失败</div>
<div className="break-all text-red/80">{readableMessageError(messageError)}</div>
</div>
</div>
)}
{/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork), {/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork),
历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt), 历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt),
新会话都包含到本回合答案为止。常驻不闪现。 */} 新会话都包含到本回合答案为止。常驻不闪现。 */}
@@ -610,7 +654,11 @@ function MessageItemBase({
if (message.role === 'assistant') { if (message.role === 'assistant') {
return ( return (
<div ref={ref} className="min-w-0 max-w-full"> <div
ref={ref}
className="min-w-0 max-w-full"
style={{ animation: 'fade-up 400ms cubic-bezier(0.23, 1, 0.32, 1) both' }}
>
<AssistantTurn <AssistantTurn
messages={[message]} messages={[message]}
tools={tools} tools={tools}
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,9 @@ import { LuChevronDown, LuPlus, LuX } from 'react-icons/lu'
/** /**
* 右侧通用标签面板(TabPanel,ZCode 式)。 * 右侧通用标签面板(TabPanel,ZCode 式)。
* 面板承载所有标签的通用机制:标签条、˅ 标签列表下拉(逐个关闭、收起面板)、 * 面板承载所有标签的通用机制:平铺标签条(所有打开的 tab 一起展示,点击切换,
* 标签右键菜单(关闭/关闭其他)、空态「打开标签页」宫格、左缘宽度拖拽。 * 悬停显示关闭、右键菜单:关闭/关闭其他/收起面板)、+ 新增标签、
* 空态「打开标签页」宫格、左缘宽度拖拽。
* 具体标签(浏览器等)通过 tabs 传入内容,与面板机制完全解耦; * 具体标签(浏览器等)通过 tabs 传入内容,与面板机制完全解耦;
* 新增标签类型只需在宿主的标签注册表里登记,面板零改动。 * 新增标签类型只需在宿主的标签注册表里登记,面板零改动。
*/ */
@@ -58,7 +59,6 @@ export function SidePanel({
onOverlayChange onOverlayChange
}: SidePanelProps): JSX.Element { }: SidePanelProps): JSX.Element {
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null) const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const [listOpen, setListOpen] = useState(false)
const [plusOpen, setPlusOpen] = useState(false) const [plusOpen, setPlusOpen] = useState(false)
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; tabId: string } | null>(null) const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
@@ -70,42 +70,49 @@ export function SidePanel({
onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta))) onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta)))
} }
const onUp = (): void => { const onUp = (): void => {
if (!dragRef.current) return
dragRef.current = null dragRef.current = null
document.body.style.userSelect = ''
} }
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
return () => { return () => {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
document.body.style.userSelect = ''
} }
}, [onWidthChange]) }, [onWidthChange])
const startResize = (e: React.PointerEvent): void => { const startResize = (e: React.PointerEvent): void => {
// preventDefault 必须在 pointerdown 上:否则 Chromium 会把它当作文本选择
// 手势的锚点,拖动经过聊天区/面板文字时把沿途内容选中;拖拽期间再对
// body 全局禁选(双保险,覆盖手势未被拦截的边缘情况),松手恢复
e.preventDefault()
dragRef.current = { startX: e.clientX, startWidth: width } dragRef.current = { startX: e.clientX, startWidth: width }
document.body.style.userSelect = 'none'
} }
// 菜单打开时 Esc 关闭 // 菜单打开时 Esc 关闭
useEffect(() => { useEffect(() => {
if (!listOpen && !plusOpen && !ctxMenu) return if (!plusOpen && !ctxMenu) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
setListOpen(false)
setPlusOpen(false) setPlusOpen(false)
setCtxMenu(null) setCtxMenu(null)
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [listOpen, plusOpen, ctxMenu]) }, [plusOpen, ctxMenu])
// 覆盖层开合上报(宿主借此暂停原生视图)。卸载必须归位:面板在菜单开着时 // 覆盖层开合上报(宿主借此暂停原生视图)。卸载必须归位:面板在菜单开着时
// 被收起(˅ 列表「收起面板」与 setListOpen 同批提交,归位 effect 不再执行), // 被收起(与 setCtxMenu/setPlusOpen 同批提交,归位 effect 不再执行),
// 宿主的 suspended 会永久卡 true,下次展开面板时原生视图被隐藏成一片空白。 // 宿主的 suspended 会永久卡 true,下次展开面板时原生视图被隐藏成一片空白。
// cleanup 在依赖变化时也会短暂触发 false→当前值,同一提交内批处理合并不闪烁。 // cleanup 在依赖变化时也会短暂触发 false→当前值,同一提交内批处理合并不闪烁。
useEffect(() => { useEffect(() => {
onOverlayChange?.(listOpen || plusOpen || ctxMenu !== null) onOverlayChange?.(plusOpen || ctxMenu !== null)
return () => onOverlayChange?.(false) return () => onOverlayChange?.(false)
}, [listOpen, plusOpen, ctxMenu, onOverlayChange]) }, [plusOpen, ctxMenu, onOverlayChange])
const current = tabs.find((t) => t.id === activeId) ?? tabs[tabs.length - 1] ?? null const current = tabs.find((t) => t.id === activeId) ?? tabs[tabs.length - 1] ?? null
@@ -125,81 +132,45 @@ export function SidePanel({
{tabs.length > 0 && ( {tabs.length > 0 && (
<div className="relative flex h-8 shrink-0 items-center gap-1 border-b border-line bg-surface px-1.5"> <div className="relative flex h-8 shrink-0 items-center gap-1 border-b border-line bg-surface px-1.5">
{/* 面板 tag 本身即下拉按钮(与网页 tab 条的 ˅ 列表按钮形态区分) */} {/* 标签条:所有打开的 tab 平铺展示(浏览器式);超出宽度横向滚动 */}
<div className="relative shrink-0"> <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<button {tabs.map((tab) => {
type="button" const active = tab.id === current?.id
aria-expanded={listOpen} return (
onClick={() => setListOpen((v) => !v)} <div
onContextMenu={(e) => { key={tab.id}
e.preventDefault() aria-current={active || undefined}
setCtxMenu({ onClick={() => onSelectTab(tab.id)}
x: Math.min(e.clientX, window.innerWidth - 170), onContextMenu={(e) => {
y: Math.min(e.clientY, window.innerHeight - 110), e.preventDefault()
tabId: current?.id ?? '' setCtxMenu({
}) x: Math.min(e.clientX, window.innerWidth - 170),
}} y: Math.min(e.clientY, window.innerHeight - 140),
className={`flex h-7 items-center gap-1.5 rounded-md border px-2 text-[12px] transition-colors ${ tabId: tab.id
listOpen })
? 'border-accent/50 bg-accent-tint/40 text-accent-ink' }}
: 'border-line bg-surface text-ink-2 hover:border-accent/40 hover:text-ink' className={`group flex h-7 shrink-0 cursor-default select-none items-center gap-1.5 rounded-md px-2 text-[12px] transition-colors ${
}`} active ? 'bg-inset text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
> }`}
{current && ( >
<> <tab.icon size={12} className="shrink-0" />
<current.icon size={12} className="shrink-0" /> <span className="min-w-0 max-w-32 truncate">{tab.title}</span>
<span className="min-w-0 max-w-32 truncate">{current.title}</span>
</>
)}
<LuChevronDown
size={11}
className={`shrink-0 text-ink-3 transition-transform ${listOpen ? 'rotate-180' : ''}`}
/>
</button>
{listOpen && (
<>
<div className="fixed inset-0 z-20" onClick={() => setListOpen(false)} />
<div className="absolute left-0 top-8 z-30 w-56 rounded-lg border border-line bg-surface py-1.5 shadow-overlay">
<div className="px-3 pb-1 pt-1 text-[11px] text-ink-3">打开的tab</div>
{tabs.map((tab) => (
<div
key={tab.id}
onClick={() => {
onSelectTab(tab.id)
setListOpen(false)
}}
className="group flex cursor-default items-center gap-2 px-2 py-1.5 hover:bg-hover"
>
<tab.icon size={12} className="shrink-0 text-ink-3" />
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">{tab.title}</span>
<button
type="button"
aria-label={`关闭${tab.title}标签`}
onClick={(e) => {
e.stopPropagation()
onCloseTab(tab.id)
}}
className="shrink-0 rounded p-0.5 text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
>
<LuX size={11} />
</button>
</div>
))}
<div className="my-1 border-t border-line-soft" />
<button <button
type="button" type="button"
onClick={() => { aria-label={`关闭${tab.title}标签`}
setListOpen(false) onClick={(e) => {
onCollapse() e.stopPropagation()
onCloseTab(tab.id)
}} }}
className="w-full px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover" className={`shrink-0 rounded p-0.5 text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2 ${
active ? '' : 'opacity-0 group-hover:opacity-100'
}`}
> >
收起面板 <LuX size={11} />
</button> </button>
</div> </div>
</> )
)} })}
</div> </div>
{/* 新增标签:列出注册表中的类型,已打开的置灰 */} {/* 新增标签:列出注册表中的类型,已打开的置灰 */}
@@ -220,7 +191,7 @@ export function SidePanel({
{plusOpen && ( {plusOpen && (
<> <>
<div className="fixed inset-0 z-20" onClick={() => setPlusOpen(false)} /> <div className="fixed inset-0 z-20" onClick={() => setPlusOpen(false)} />
<div className="absolute left-0 top-8 z-30 w-40 rounded-lg border border-line bg-surface py-1 shadow-overlay"> <div className="absolute right-0 top-8 z-30 w-40 rounded-lg border border-line bg-surface py-1 shadow-overlay">
{available.map(({ id, title, icon: Icon }) => { {available.map(({ id, title, icon: Icon }) => {
const opened = tabs.some((t) => t.id === id) const opened = tabs.some((t) => t.id === id)
return ( return (
@@ -285,6 +256,18 @@ export function SidePanel({
<LuX size={11} className="shrink-0" /> <LuX size={11} className="shrink-0" />
关闭其他标签页 关闭其他标签页
</button> </button>
<div className="my-1 border-t border-line-soft" />
<button
type="button"
onClick={() => {
setCtxMenu(null)
onCollapse()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover"
>
<LuChevronDown size={11} className="shrink-0" />
收起面板
</button>
</div> </div>
</> </>
)} )}
@@ -294,7 +277,7 @@ export function SidePanel({
{tabs.length === 0 ? ( {tabs.length === 0 ? (
<EmptyState available={available} onOpen={onOpenTab} /> <EmptyState available={available} onOpen={onOpenTab} />
) : ( ) : (
<div className="relative min-h-0 flex-1">{current?.content ?? null}</div> <div className="relative min-h-0 flex-1 bg-page">{current?.content ?? null}</div>
)} )}
</aside> </aside>
) )
@@ -15,7 +15,7 @@ import {
import { LuPanelLeft } from 'react-icons/lu' import { LuPanelLeft } from 'react-icons/lu'
import { Button } from '@/components/atoms/Button' import { Button } from '@/components/atoms/Button'
import { GlideMenu } from '@/components/GlideMenu' import { GlideMenu } from '@/components/GlideMenu'
import { PluginIcon } from '@/plugins/PluginPageFrame' import { PluginIconValue } from '@/plugins/plugin-icons'
import type { PluginInfo, SessionMeta, SessionState, WorkspaceMeta } from '@shared/types' import type { PluginInfo, SessionMeta, SessionState, WorkspaceMeta } from '@shared/types'
/** 可折叠工作区导航(beautifului.dev Sidebar Nav)。 /** 可折叠工作区导航(beautifului.dev Sidebar Nav)。
@@ -444,8 +444,8 @@ export function SidebarNav({
onClick={() => onOpenPlugin(plugin.manifest.id)} onClick={() => onOpenPlugin(plugin.manifest.id)}
className={`${navRowClass} ${active ? 'bg-hover-2 text-ink' : ''}`} className={`${navRowClass} ${active ? 'bg-hover-2 text-ink' : ''}`}
> >
<span className="flex size-5 shrink-0 items-center justify-center"> <span className="flex size-5 shrink-0 items-center justify-center text-ink-2">
<PluginIcon svg={plugin.manifest.icon} size={18} /> <PluginIconValue icon={plugin.manifest.icon} size={18} />
</span> </span>
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3"> <span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[13px] font-medium text-ink-3">
{plugin.manifest.name} {plugin.manifest.name}
@@ -455,8 +455,9 @@ export function SidebarNav({
})} })}
</GlideMenu> </GlideMenu>
{/* 会话列表区 */} {/* 会话列表区:折叠态 margin 收敛为 1px,让「会话」切换图标与上方
<div className="mt-3 min-h-0 flex-1 overflow-y-auto"> 导航图标保持同一垂直节奏(行高 32 + 间隙 1px 等距) */}
<div className={`min-h-0 flex-1 overflow-y-auto ${collapsed ? 'mt-px' : 'mt-3'}`}>
<div className="relative mx-2 mb-1 h-8"> <div className="relative mx-2 mb-1 h-8">
<div className="sidebar-copy absolute inset-0"> <div className="sidebar-copy absolute inset-0">
<button <button
@@ -1,23 +1,21 @@
import { useEffect, useState, type JSX, type ReactNode } from 'react' import { useEffect, useState, type JSX } from 'react'
import { LuPanelRight } from 'react-icons/lu' import { LuPanelRight } from 'react-icons/lu'
/** /**
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。 * 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」); * 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/ * 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。 * 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
* (插件悬浮状态胶囊不再挂这里:改悬浮于 main 区域右上角。)
*/ */
export function TitleBar({ export function TitleBar({
title, title,
panelOpen, panelOpen,
onTogglePanel, onTogglePanel
extra
}: { }: {
title?: string title?: string
panelOpen: boolean panelOpen: boolean
onTogglePanel: () => void onTogglePanel: () => void
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
extra?: ReactNode
}): JSX.Element { }): JSX.Element {
const name = title?.trim() const name = title?.trim()
const [maximized, setMaximized] = useState(false) const [maximized, setMaximized] = useState(false)
@@ -27,7 +25,6 @@ export function TitleBar({
return ( return (
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]"> <div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]">
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div> <div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
{extra}
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]"> <div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
<button <button
type="button" type="button"
@@ -105,7 +105,7 @@ function extractResultText(result: unknown): string {
function toolMeta(name: string): { icon: keyof typeof STEP_ICONS; label: string } { function toolMeta(name: string): { icon: keyof typeof STEP_ICONS; label: string } {
if (/^read|get|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' } if (/^read|get|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' }
if (/^write|^edit|^multiedit|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' } if (/^write|^edit|^multiedit|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' }
if (/^bash|^shell|^terminal|终端/i.test(name)) return { icon: 'run', label: '终端' } if (/^bash|^shell|^terminal|^powershell|终端/i.test(name)) return { icon: 'run', label: '终端' }
if (/read|get|search|query|fetch|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' } if (/read|get|search|query|fetch|list|查|读|搜/i.test(name)) return { icon: 'read', label: '读取' }
if (/write|edit|update|create|delete|patch|save|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' } if (/write|edit|update|create|delete|patch|save|写|改|删|建|核/i.test(name)) return { icon: 'write', label: '编辑' }
return { icon: 'run', label: name } return { icon: 'run', label: name }
@@ -4,29 +4,33 @@ import { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/* 设计规范:矩形控件统一 5px 圆角(rounded-control),rounded-full 仅限 /* beautifului.dev 原版 Button:Pill 胶囊形是应用的核心按钮语言。显式对称
* icon 圆钮/状态点/头像。动效走全局默认 400ms + ease(@theme 注入)。 */ * padding(不锁高度)保证上下留白永远相等;filled 变体带一道 1px 内高光;
* 按压回缩 0.96、150ms ease-out。 */
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
export const buttonVariants = cva( export const buttonVariants = cva(
`inline-flex items-center justify-center font-medium select-none `inline-flex items-center justify-center font-medium select-none
transition-[transform,background-color,opacity] transition-[transform,background-color,opacity] duration-150 ease-out
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none`, active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
{ {
variants: { variants: {
variant: { variant: {
primary: "bg-ink text-canvas hover:opacity-90", primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover", secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
ghost: "bg-hover-2 text-ink hover:bg-line-strong", ghost: "bg-hover-2 text-ink hover:bg-line-strong",
accent: "bg-accent text-white hover:bg-accent-ink", accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
success: "bg-green text-white hover:brightness-95", success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
/* transparent until hovered — for dense toolbars/action rows */ /* transparent until hovered — for dense toolbars/action rows */
quiet: "text-ink hover:bg-hover", quiet: "text-ink hover:bg-hover",
}, },
size: { size: {
/* compact toolbar control — fixed height, lighter weight */ /* compact toolbar pill — fixed height, lighter weight */
xs: "h-7 rounded-control px-2.5 text-[12px] font-normal leading-none gap-1", xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action button */ /* canonical action pill — 27px tall, roomy sides */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-control gap-1.5", sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-control gap-2", md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
}, },
}, },
defaultVariants: { variant: "secondary", size: "md" }, defaultVariants: { variant: "secondary", size: "md" },
@@ -4,20 +4,21 @@ import {
LuArrowRight, LuArrowRight,
LuChevronDown, LuChevronDown,
LuCircleAlert, LuCircleAlert,
LuDownload,
LuGlobe, LuGlobe,
LuNetwork,
LuPlus, LuPlus,
LuRotateCw, LuRotateCw,
LuX LuX
} from 'react-icons/lu' } from 'react-icons/lu'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types' import type { BrowserUiState } from '@shared/types'
import { BrowserSlot } from './BrowserSlot' import { BrowserSlot } from './BrowserSlot'
import { BrowserProxyForm } from './BrowserProxyForm'
/** /**
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。 * 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/代理浮层)。
* 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView * 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView
* (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。 * (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。
* 任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。 * 任何全屏浮层(设置/搜索/代理)出现时必须卸载 Slot 隐藏原生视图。
*/ */
interface BrowserPanelProps { interface BrowserPanelProps {
@@ -35,14 +36,12 @@ interface BrowserPanelProps {
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */ /** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
onCloseAllTabs: () => void onCloseAllTabs: () => void
onAcknowledgeRisk: () => void onAcknowledgeRisk: () => void
onDetectSources: () => Promise<BrowserSource[]>
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
} }
export function BrowserPanel(props: BrowserPanelProps): JSX.Element { export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
const { state, suspended } = props const { state, suspended } = props
const [address, setAddress] = useState('') const [address, setAddress] = useState('')
const [importOpen, setImportOpen] = useState(false) const [proxyOpen, setProxyOpen] = useState(false)
const [tabListOpen, setTabListOpen] = useState(false) const [tabListOpen, setTabListOpen] = useState(false)
const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null) const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
@@ -80,7 +79,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
const riskBanner = state && !state.riskAcknowledged const riskBanner = state && !state.riskAcknowledged
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上) // 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
const overlayOpen = importOpen || tabListOpen || tabMenu !== null const overlayOpen = proxyOpen || tabListOpen || tabMenu !== null
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单) // 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
const hideCause = suspended || overlayOpen const hideCause = suspended || overlayOpen
@@ -356,11 +355,12 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
</div> </div>
<button <button
type="button" type="button"
onClick={() => setImportOpen(true)} onClick={() => setProxyOpen(true)}
title="导入本机浏览器登录态" title="代理设置"
aria-expanded={proxyOpen}
className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2" className="flex size-7 items-center justify-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
> >
<LuDownload size={13} /> <LuNetwork size={13} />
</button> </button>
</div> </div>
@@ -384,8 +384,9 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
)} )}
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。 {/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白 */} * 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白。
<div className="relative min-h-0 flex-1 bg-page"> * 左内缩 4px:原生视图盖住一切 DOM,必须让出侧面板左缘的拖拽把手 */}
<div className="relative min-h-0 flex-1 bg-page pl-1">
{activeTab && !slotHidden ? ( {activeTab && !slotHidden ? (
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} /> <BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
) : activeTab ? ( ) : activeTab ? (
@@ -403,69 +404,25 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
</div> </div>
{/* Agent 操作轨迹 */} {/* Agent 操作轨迹 */}
{state?.trace && !importOpen && ( {state?.trace && (
<div className="shrink-0 truncate border-t border-line bg-surface px-3 py-1.5 text-[10.5px] text-ink-3"> <div className="shrink-0 truncate border-t border-line bg-surface px-3 py-1.5 text-[10.5px] text-ink-3">
{state.trace} {state.trace}
</div> </div>
)} )}
{/* 配置导入浮层(覆盖面板,出现时原生视图已隐藏) */} {/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */}
{importOpen && ( {proxyOpen && <ProxyOverlay onClose={() => setProxyOpen(false)} />}
<ImportOverlay
state={state}
onClose={() => setImportOpen(false)}
onDetectSources={props.onDetectSources}
onImportProfile={props.onImportProfile}
/>
)}
</div> </div>
) )
} }
// ---- 配置导入浮层 ---- // ---- 浮层薄壳:标题栏 + 关闭;表单本体在共用组件(设置页二级页同样复用) ----
function ImportOverlay({
state,
onClose,
onDetectSources,
onImportProfile
}: {
state: BrowserUiState | null
onClose: () => void
onDetectSources: () => Promise<BrowserSource[]>
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
}): JSX.Element {
const [sources, setSources] = useState<BrowserSource[] | null>(null)
const [selected, setSelected] = useState<{ sourceId: string; profileDir: string } | null>(null)
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<BrowserImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
onDetectSources()
.then((list) => {
setSources(list)
const first = list[0]
if (first?.profiles[0]) setSelected({ sourceId: first.id, profileDir: first.profiles[0].dir })
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
}, [onDetectSources])
const runImport = (): void => {
if (!selected) return
setBusy(true)
setError(null)
setResult(null)
onImportProfile(selected.sourceId, selected.profileDir)
.then(setResult)
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
function ProxyOverlay({ onClose }: { onClose: () => void }): JSX.Element {
return ( return (
<div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]"> <div className="absolute inset-0 z-20 flex flex-col bg-surface/95 backdrop-blur-[2px]">
<div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3"> <div className="flex h-10 shrink-0 items-center justify-between border-b border-line px-3">
<div className="text-[12.5px] font-medium text-ink-2">导入本机浏览器登录态</div> <div className="text-[12.5px] font-medium text-ink-2">代理设置</div>
<button <button
type="button" type="button"
aria-label="关闭" aria-label="关闭"
@@ -475,86 +432,8 @@ function ImportOverlay({
<LuX size={13} /> <LuX size={13} />
</button> </button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="min-h-0 flex-1 overflow-y-auto p-3 text-[12px] text-ink-2"> <BrowserProxyForm />
<p className="mb-3 leading-relaxed text-ink-3">
选择本机浏览器的一个配置(Profile),把其中各网站的登录态(Cookie / Local Storage)导入到内置受管浏览器。
导入前请<span className="font-medium text-ink-2">完全退出</span>该浏览器,并关闭本面板的所有标签页。
</p>
{state?.imported && (
<div className="mb-3 rounded border border-line bg-inset px-2.5 py-2 text-[11.5px] text-ink-3">
当前导入来源:{state.imported.browser} · {state.imported.profile}
{state.imported.cookies ? '(含 Cookie)' : ''}
</div>
)}
{sources === null && !error && <p className="py-6 text-center text-ink-3">正在检测本机浏览器…</p>}
{sources?.length === 0 && <p className="py-6 text-center text-ink-3">未检测到可导入的本机浏览器(支持 Chrome / Edge / Brave / Chromium)</p>}
<div className="flex flex-col gap-2">
{sources?.map((source) => (
<div key={source.id} className="rounded border border-line">
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[12px] font-medium text-ink-2">
<LuChevronDown size={12} className="text-ink-3" />
{source.name}
<span className="text-[10.5px] font-normal text-ink-3">({source.profiles.length} 个配置)</span>
</div>
<div className="flex flex-col gap-1 px-2 pb-2">
{source.profiles.map((profile) => {
const active = selected?.sourceId === source.id && selected.profileDir === profile.dir
return (
<button
key={profile.dir}
type="button"
onClick={() => setSelected({ sourceId: source.id, profileDir: profile.dir })}
className={`flex items-center justify-between rounded px-2.5 py-1.5 text-left text-[11.5px] transition-colors ${
active ? 'bg-accent-tint text-accent-ink' : 'text-ink-2 hover:bg-hover'
}`}
>
<span className="min-w-0 truncate">
{profile.name}
<span className="ml-1.5 text-[10.5px] text-ink-3">{profile.dir}</span>
</span>
{active && <span className="shrink-0 text-[10px]">已选择</span>}
</button>
)
})}
</div>
</div>
))}
</div>
{error && (
<div className="mt-3 rounded border border-red/40 bg-red-tint px-2.5 py-2 text-[11.5px] text-red">{error}</div>
)}
{result && (
<div
className={`mt-3 rounded border px-2.5 py-2 text-[11.5px] leading-relaxed ${
result.ok ? 'border-green-200 bg-green-tint text-green' : 'border-red/40 bg-red-tint text-red'
}`}
>
{result.message}
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-line px-3 py-2.5">
<button
type="button"
onClick={onClose}
className="rounded px-3 py-1.5 text-[12px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
关闭
</button>
<button
type="button"
disabled={!selected || busy}
onClick={runImport}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? '导入中…' : '导入登录态'}
</button>
</div> </div>
</div> </div>
) )
@@ -0,0 +1,135 @@
/**
* 受管浏览器代理设置表单(面板浮层与设置页二级页共用)。
* 只作用于 persist:aso-browser 分区;保存即生效(主进程 setProxy + 断开在途连接)。
* 自含数据源:自行订阅 browser 事件流获取当前生效设置。
*/
import { useEffect, useState, type JSX } from 'react'
import type { BrowserProxySettings, BrowserUiState } from '@shared/types'
const PROXY_MODES: Array<{ id: BrowserProxySettings['mode']; label: string }> = [
{ id: 'system', label: '跟随系统' },
{ id: 'direct', label: '直连' },
{ id: 'fixed', label: '手动代理' }
]
export function BrowserProxyForm(): JSX.Element {
const [state, setState] = useState<BrowserUiState | null>(null)
const [mode, setMode] = useState<BrowserProxySettings['mode']>('system')
const [rules, setRules] = useState('')
const [bypass, setBypass] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
// 当前生效设置:首查 + 事件跟随(主进程广播,保存成功后表单同步)
useEffect(() => {
let alive = true
window.api
.browserGetState()
.then((s) => {
if (alive) setState(s)
})
.catch(() => undefined)
const off = window.api.onBrowserEvent((event) => {
if (event.type === 'state' && alive) setState(event.state)
})
return () => {
alive = false
off()
}
}, [])
const current = state?.proxy
useEffect(() => {
if (!current) return
setMode(current.mode)
setRules(current.mode === 'fixed' ? (current.rules ?? '') : '')
setBypass(current.bypass ?? '')
}, [current?.mode, current?.rules, current?.bypass])
const save = (): void => {
setBusy(true)
setError(null)
setSaved(false)
window.api
.browserSetProxy({ mode, rules: rules.trim(), bypass: bypass.trim() })
.then((p) => {
setMode(p.mode)
setRules(p.rules ?? '')
setBypass(p.bypass ?? '')
setSaved(true)
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setBusy(false))
}
const inputCls =
'w-full rounded border border-line bg-inset px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-accent focus:bg-surface'
return (
<div className="text-[12px] text-ink-2">
<div className="flex w-fit items-center rounded-control border border-line bg-hover p-0.5">
{PROXY_MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={`h-7 rounded-[6px] px-3 text-[12px] transition-colors ${
mode === m.id ? 'bg-surface font-medium text-ink shadow-card' : 'text-ink-3 hover:text-ink-2'
}`}
>
{m.label}
</button>
))}
</div>
{mode === 'fixed' && (
<div className="mt-3 flex flex-col gap-2.5">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">代理规则(多协议用分号分隔)</span>
<input
value={rules}
onChange={(e) => setRules(e.target.value)}
placeholder="127.0.0.1:7890 或 http=127.0.0.1:7890;https=127.0.0.1:7890"
spellCheck={false}
className={inputCls}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-ink-3">绕过列表(逗号分隔,可留空)</span>
<input
value={bypass}
onChange={(e) => setBypass(e.target.value)}
placeholder="<local>,*.example.com,127.0.0.1"
spellCheck={false}
className={inputCls}
/>
</label>
<p className="text-[10.5px] leading-4 text-ink-3">
规则语法与 Chromium 一致:host:port 应用于全部协议;也可按协议指定(http=…;https=…;socks=…)。
</p>
</div>
)}
{error && (
<div className="mt-3 rounded border border-red/40 bg-red-tint px-2.5 py-2 text-[11.5px] text-red">{error}</div>
)}
{saved && !error && (
<div className="mt-3 rounded border border-green-200 bg-green-tint px-2.5 py-2 text-[11.5px] text-green">
已保存并立即生效(在途连接已重置)。
</div>
)}
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
disabled={busy || (mode === 'fixed' && !rules.trim())}
onClick={save}
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-page transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? '应用中…' : '保存并应用'}
</button>
</div>
</div>
)
}
@@ -178,22 +178,27 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
return { ...state, ready: true, sessionState: event.state } return { ...state, ready: true, sessionState: event.state }
case 'session_error': case 'session_error':
return { ...state, sessionError: event.message } return { ...state, sessionError: event.message }
case 'session_switched': case 'session_switched': {
// 同一会话的技术性重发(初始化重放等)不得打断进行中的回合:保留回合
// 状态,仅刷新会话数据与历史。真正的切换(新会话/切换/分叉)会话 id
// 不同,走完整重置。
const sameSession = state.sessionState?.sessionId === event.state.sessionId
return { return {
...state, ...state,
ready: true, ready: true,
error: null, error: null,
retry: null, retry: null,
streaming: false, streaming: sameSession ? state.streaming : false,
turnActive: false, turnActive: sameSession ? state.turnActive : false,
queue: { steering: [], followUp: [] }, queue: sameSession ? state.queue : { steering: [], followUp: [] },
stats: null, stats: null,
compacting: false, compacting: sameSession ? state.compacting : false,
compactionNotice: null, compactionNotice: sameSession ? state.compactionNotice : null,
sessionState: event.state, sessionState: event.state,
messages: historyToMessages(event.messages), messages: historyToMessages(event.messages),
tools: historyToTools(event.messages) tools: historyToTools(event.messages)
} }
}
case 'agent_start': case 'agent_start':
return { ...state, streaming: true, turnActive: true, error: null, retry: null } return { ...state, streaming: true, turnActive: true, error: null, retry: null }
case 'agent_end': case 'agent_end':
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types' import type { BrowserUiState } from '@shared/types'
/** /**
* 内置受管浏览器面板状态与操作。 * 内置受管浏览器面板状态与操作。
@@ -16,8 +16,6 @@ export function useBrowser(): {
closeTab: (tabId: string) => void closeTab: (tabId: string) => void
closeBrowser: () => void closeBrowser: () => void
acknowledgeRisk: () => void acknowledgeRisk: () => void
detectSources: () => Promise<BrowserSource[]>
importProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
} { } {
const [state, setState] = useState<BrowserUiState | null>(null) const [state, setState] = useState<BrowserUiState | null>(null)
@@ -50,8 +48,6 @@ export function useBrowser(): {
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)), selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)), closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
closeBrowser: wrap(() => window.api.browserClose()), closeBrowser: wrap(() => window.api.browserClose()),
acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk()), acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk())
detectSources: () => window.api.browserDetectSources(),
importProfile: (sourceId: string, profileDir: string) => window.api.browserImportProfile(sourceId, profileDir)
} }
} }
+18 -6
View File
@@ -2,8 +2,7 @@ import type {
AgentUiEvent, AgentUiEvent,
AppSettings, AppSettings,
AttachmentInfo, AttachmentInfo,
BrowserImportResult, BrowserProxySettings,
BrowserSource,
BrowserUiEvent, BrowserUiEvent,
BrowserUiState, BrowserUiState,
ChatResources, ChatResources,
@@ -26,6 +25,7 @@ import type {
WorkspaceFile, WorkspaceFile,
WorkspaceMeta WorkspaceMeta
} from '@shared/types' } from '@shared/types'
import type { PluginSettingsSchemas, PluginSettingsValuesMap } from '@aso/plugin-sdk'
export type { export type {
AgentUiEvent, AgentUiEvent,
@@ -35,8 +35,6 @@ export type {
SessionMeta, SessionMeta,
SessionState, SessionState,
SessionStatsInfo, SessionStatsInfo,
BrowserImportResult,
BrowserSource,
BrowserUiEvent, BrowserUiEvent,
BrowserUiState BrowserUiState
} }
@@ -92,6 +90,7 @@ declare global {
getSystemPrompt: () => Promise<string> getSystemPrompt: () => Promise<string>
setThemeColor: (id: string) => Promise<void> setThemeColor: (id: string) => Promise<void>
setThemeMode: (mode: ThemeMode) => Promise<void> setThemeMode: (mode: ThemeMode) => Promise<void>
setResourceEnabled: (kind: 'skill' | 'command', name: string, enabled: boolean) => Promise<void>
resolveApproval: (id: string, approved: boolean) => Promise<void> resolveApproval: (id: string, approved: boolean) => Promise<void>
onEvent: (callback: (event: AgentUiEvent) => void) => () => void onEvent: (callback: (event: AgentUiEvent) => void) => () => void
browserGetState: () => Promise<BrowserUiState> browserGetState: () => Promise<BrowserUiState>
@@ -111,13 +110,26 @@ declare global {
revision: number revision: number
bounds: { x: number; y: number; width: number; height: number } bounds: { x: number; y: number; width: number; height: number }
}) => Promise<void> }) => Promise<void>
browserDetectSources: () => Promise<BrowserSource[]> browserGetProxy: () => Promise<BrowserProxySettings>
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult> browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings>
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
// ---- 插件系统 ---- // ---- 插件系统 ----
pluginsList: () => Promise<PluginInfo[]> pluginsList: () => Promise<PluginInfo[]>
pluginsUiState: () => Promise<PluginsUiState> pluginsUiState: () => Promise<PluginsUiState>
pluginsDirs: () => Promise<{ user: string[]; project: string[]; global: string[] }>
pluginsReadRenderer: (id: string) => Promise<string>
pluginsSettingsSchemas: () => Promise<PluginSettingsSchemas>
pluginsSettingsValues: () => Promise<PluginSettingsValuesMap>
pluginsSetSettingValue: (id: string, key: string, value: unknown) => Promise<void>
pluginsSetSettingsNav: (id: string, on: boolean) => Promise<void>
pluginsSettingsAction: (id: string, key: string) => Promise<void>
pluginsPickPath: (mode: 'file' | 'directory' | 'image') => Promise<string | null>
pluginsReadImage: (path: string) => Promise<string | null>
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void) => () => void
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void) => () => void
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void> pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
pluginsOpenDataDir: (id: string) => Promise<boolean>
pluginsClearData: (id: string) => Promise<void>
pluginsReload: () => Promise<void> pluginsReload: () => Promise<void>
pluginsOpenView: (view: string, params?: unknown) => Promise<void> pluginsOpenView: (view: string, params?: unknown) => Promise<void>
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise<void> pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise<void>
@@ -0,0 +1,15 @@
/**
* 渲染层全局错误采集:window.onerror / unhandledrejection / 未处理的 React 边界外错误
* 统一走 console.error,由宿主 console-message 桥归档进日志文件(main/index.ts)。
* Agent 自诊断依赖:任何渲染层异常都必须能在 userData/logs/ 里看到。
*/
window.addEventListener('error', (e) => {
// 资源加载失败(img/script)没有 error 对象,也一并记录
const detail = e.error instanceof Error ? `${e.error.stack ?? e.error.message}` : `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`
console.error(`[window.onerror] ${detail}`)
})
window.addEventListener('unhandledrejection', (e) => {
const reason = e.reason instanceof Error ? (e.reason.stack ?? e.reason.message) : String(e.reason)
console.error(`[unhandledrejection] ${reason}`)
})
+1
View File
@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import './styles/globals.css' import './styles/globals.css'
import './lib/error-report'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
@@ -0,0 +1,329 @@
/**
* 悬浮状态胶囊(B3 槽位,复刻 ZCode「最小化药丸 ⇄ 展开面板」二态):
* - 所有插件共用一个胶囊,数据同源 plugins:uiState(api.ui.setStatusItem);
* 挂在 main 区域右上角悬浮(侧面板打开时自动左移让位,由挂载点控制)
* - 宿主为每条状态记录 updatedAt:最小化药丸展示「插件最新的消息」
* (tone 点 + 插件图标 + 文本 + 总数角标),点击展开面板
* - 展开面板:插件区域头 = 插件名(第一个区域的右侧是收回药丸的 ⤡);
* 区域内 detail 的**分组是手风琴**——默认收起只显示「标题 + 行尾徽标」,
* 点击展开 rows(ZCode 式:计划 ›);无标题分组直接平铺 rows
* - 面板高度固定,内容溢出滚动;点击面板外部 / Esc 收回最小化
*/
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { LuChevronDown, LuChevronRight, LuMinimize2 } from 'react-icons/lu'
import type { PluginInfo, PluginStatusEntry } from '@shared/types'
import type { StatusDetailRow } from '@aso/plugin-sdk'
import { PluginIconValue } from './plugin-icons'
/** 胶囊前导色点:tone 的安静表达(底保持中性,不整块染色) */
export const TONE_DOT_CLASS: Record<string, string> = {
default: 'bg-ink-3',
accent: 'bg-accent',
success: 'bg-green',
warn: 'bg-orange',
danger: 'bg-red'
}
/** 行尾数值的着色(ZCode 式:+76 绿 / -40 红 的纯色文本,不用底色块) */
const TRAILING_TONE_CLASS: Record<string, string> = {
default: 'text-ink-3',
accent: 'text-accent',
success: 'text-green',
warn: 'text-orange',
danger: 'text-red'
}
/** 收起区域记忆(跨启动) */
const SECTION_STORAGE_KEY = 'aso.floatcapsule.collapsed.v1'
function loadCollapsedSections(): string[] {
try {
const raw = JSON.parse(localStorage.getItem(SECTION_STORAGE_KEY) ?? '[]')
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
}
/** 明细行(数据驱动:图标 + 标签 + 次级说明 + 行尾彩色数值,行内可深链) */
function StatusRows({
rows,
onOpenView
}: {
rows: StatusDetailRow[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
return (
<>
{rows.map((row, ri) => {
const clickable = Boolean(row.openView)
const content = (
<>
{row.icon && <PluginIconValue icon={row.icon} size={14} className="shrink-0 text-ink-2" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] leading-4 text-ink">{row.label}</span>
{row.detail && <span className="mt-0.5 block truncate text-[11px] leading-4 text-ink-3">{row.detail}</span>}
</span>
{row.trailing && (
<span className={`shrink-0 text-[11.5px] font-medium ${TRAILING_TONE_CLASS[row.tone ?? 'default']}`}>
{row.trailing}
</span>
)}
</>
)
return clickable ? (
<button
key={ri}
type="button"
onClick={() => {
if (row.openView) onOpenView(row.openView.view, row.openView.params)
}}
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
{content}
</button>
) : (
<div key={ri} className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">
{content}
</div>
)
})}
</>
)
}
export function PluginFloatingCapsule({
entries,
plugins,
onOpenView
}: {
entries: PluginStatusEntry[]
plugins: PluginInfo[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
const [open, setOpen] = useState(false)
const [collapsedAreas, setCollapsedAreas] = useState<string[]>(loadCollapsedSections)
/** 分组手风琴(展开的 section 键 pluginId:itemId:idx;默认收起,会话内记忆) */
const [openSections, setOpenSections] = useState<Set<string>>(() => new Set())
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(collapsedAreas))
}, [collapsedAreas])
// 面板开着时:点击外部 / Esc 收回最小化
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
// 全部条目消失(插件停用/重载)时收回最小化
if (entries.length === 0) return null
// updatedAt 最新在前(最新消息 = 排序头)
const sorted = [...entries].sort((a, b) => b.updatedAt - a.updatedAt)
const latest = sorted[0]
const latestPlugin = plugins.find((p) => p.manifest.id === latest.pluginId)
// 按插件分组,组序沿用最新在前
const byPlugin = new Map<string, PluginStatusEntry[]>()
for (const entry of sorted) {
const arr = byPlugin.get(entry.pluginId) ?? []
arr.push(entry)
byPlugin.set(entry.pluginId, arr)
}
const toggleArea = (pluginId: string): void => {
setCollapsedAreas((prev) =>
prev.includes(pluginId) ? prev.filter((id) => id !== pluginId) : [...prev, pluginId]
)
}
const toggleSection = (key: string): void => {
setOpenSections((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
if (!open) {
return (
<div ref={rootRef} className="relative shrink-0">
<button
type="button"
title="插件状态(点击展开)"
onClick={() => setOpen(true)}
className="flex h-7 max-w-[88px] shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2.5 text-[11.5px] text-ink-2 shadow-card transition-colors hover:border-line-strong hover:text-ink"
>
<span
aria-hidden
className={`size-1.5 shrink-0 rounded-full ${TONE_DOT_CLASS[latest.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default}`}
/>
{latestPlugin && <PluginIconValue icon={latestPlugin.manifest.icon} size={12} className="shrink-0" />}
<span className="truncate">{latest.item.text}</span>
{sorted.length > 1 && (
<span className="shrink-0 rounded-full bg-hover px-1.5 text-[10px] leading-4 text-ink-3">{sorted.length}</span>
)}
</button>
</div>
)
}
return (
<div ref={rootRef} className="relative shrink-0">
<div className="absolute right-0 top-full z-50 mt-1.5 flex w-80 flex-col overflow-hidden rounded-card border border-line bg-surface text-left shadow-overlay">
{/* 最大高度封顶,高度随内容自适应;溢出才滚动 */}
<div className="max-h-[min(480px,58vh)] overflow-y-auto overscroll-contain px-1.5 py-1.5">
{[...byPlugin.entries()].map(([pluginId, pluginEntries], areaIdx) => {
const plugin = plugins.find((p) => p.manifest.id === pluginId)
const areaCollapsed = collapsedAreas.includes(pluginId)
return (
<div key={pluginId} className={areaIdx > 0 ? 'mt-1 border-t border-line pt-1' : ''}>
{/* 区域头 = 插件名(收拢在左侧);⤡ 收起按钮贴面板最右缘(仅第一个区域) */}
<div className="flex w-full items-center gap-0.5 px-1 py-0.5">
<button
type="button"
aria-expanded={!areaCollapsed}
onClick={() => toggleArea(pluginId)}
className="flex min-w-0 items-center gap-1.5 rounded-control px-1.5 py-1 text-left transition-colors hover:bg-hover"
>
{plugin ? (
<PluginIconValue icon={plugin.manifest.icon} size={14} className="shrink-0 text-ink-2" />
) : null}
<span className="min-w-0 truncate text-[12.5px] font-medium text-ink">
{plugin?.manifest.name ?? pluginId}
</span>
<LuChevronDown
size={12}
aria-hidden
className={`shrink-0 text-ink-3 transition-transform duration-150 ${areaCollapsed ? '-rotate-90' : ''}`}
/>
</button>
{areaIdx === 0 && (
<button
type="button"
aria-label="收起到悬浮胶囊"
title="收起"
onClick={() => setOpen(false)}
className="ml-auto shrink-0 rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuMinimize2 size={13} />
</button>
)}
</div>
{!areaCollapsed && (
<div className="pb-1">
{pluginEntries.map((entry) => {
const clickable = Boolean(entry.item.openView)
return (
<div key={entry.itemId}>
{entry.item.detail ? (
// 有详情:分组手风琴(默认收起,标题行 + 行尾徽标;无标题分组平铺)
entry.item.detail.sections.map((section, si) => {
if (!section.title) {
return <StatusRows key={si} rows={section.rows} onOpenView={onOpenView} />
}
const key = `${pluginId}:${entry.itemId}:${si}`
const expanded = openSections.has(key)
return (
<div key={key}>
<button
type="button"
aria-expanded={expanded}
onClick={() => toggleSection(key)}
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
{section.title}
</span>
{section.trailing && (
<span
className={`shrink-0 text-[11.5px] font-medium ${
TRAILING_TONE_CLASS[section.trailingTone ?? 'default']
}`}
>
{section.trailing}
</span>
)}
<LuChevronRight
size={12}
aria-hidden
className={`shrink-0 text-ink-3 transition-transform duration-150 ${
expanded ? 'rotate-90' : ''
}`}
/>
</button>
{expanded && (
<div className="pb-1 pl-1">
<StatusRows rows={section.rows} onOpenView={onOpenView} />
</div>
)}
</div>
)
})
) : (
// 纯文本条目行
clickable ? (
<button
type="button"
onClick={() => {
if (entry.item.openView) onOpenView(entry.item.openView.view, entry.item.openView.params)
}}
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
<span
aria-hidden
className={`size-1.5 shrink-0 rounded-full ${
TONE_DOT_CLASS[entry.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default
}`}
/>
{entry.item.icon ? (
<PluginIconValue icon={entry.item.icon} size={13} className="shrink-0 text-ink-2" />
) : null}
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
{entry.item.text}
</span>
</button>
) : (
<div className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">
<span
aria-hidden
className={`size-1.5 shrink-0 rounded-full ${
TONE_DOT_CLASS[entry.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default
}`}
/>
{entry.item.icon ? (
<PluginIconValue icon={entry.item.icon} size={13} className="shrink-0 text-ink-2" />
) : null}
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
{entry.item.text}
</span>
</div>
)
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -1,51 +0,0 @@
/**
* 插件状态胶囊(TitleBar B2 槽位):数据驱动渲染主进程广播的状态条目。
* 点击条目 = 执行其深链(openView)。聊天视图与插件视图下均可见。
*/
import type { ReactNode } from 'react'
import type { PluginStatusEntry } from '@shared/types'
const TONE_CLASS: Record<string, string> = {
default: 'border-line text-ink-2',
accent: 'border-accent/40 text-accent-ink bg-accent-tint',
warn: 'border-orange/40 text-orange bg-orange-tint',
danger: 'border-red/40 text-red bg-red-tint'
}
export function PluginStatusCapsules({
entries,
onOpenView
}: {
entries: PluginStatusEntry[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
if (entries.length === 0) return null
return (
<div className="ml-3 flex min-w-0 items-center gap-1.5 [-webkit-app-region:no-drag]">
{entries.slice(0, 4).map(({ pluginId, itemId, item }) => {
const tone = item.tone ?? 'default'
const clickable = Boolean(item.openView)
return (
<button
key={`${pluginId}:${itemId}`}
type="button"
title={item.text}
onClick={() => {
if (item.openView) onOpenView(item.openView.view, item.openView.params)
}}
className={`max-w-44 shrink-0 truncate rounded-full border px-2.5 py-0.5 text-[11px] leading-[18px] transition-transform ${
TONE_CLASS[tone] ?? TONE_CLASS.default
} ${clickable ? 'hover:opacity-80 active:scale-[0.97]' : 'cursor-default'}`}
>
{item.text}
</button>
)
})}
{entries.length > 4 && (
<span className="shrink-0 rounded-full border border-line px-2 py-0.5 text-[11px] leading-[18px] text-ink-3">
+{entries.length - 4}
</span>
)}
</div>
)
}
@@ -0,0 +1,242 @@
/**
* 插件图标规范:manifest.icon / 状态胶囊 icon 统一填 **lucide 图标名**
* (PascalCase 如 "Globe",kebab-case "layout-grid" 亦可),宿主经 react-icons/lu
* 白名单渲染——线条风格、粗细、圆角与宿主完全一致(DESIGN.md 图标规范)。
* 旧版内联 `<svg>` 字符串仍兼容(sanitizeSvg 防线继续生效);
* 名单之外或缺失的名字一律回落拼图图标,杜绝来历不明的自绘图形。
*/
import type { ReactNode } from 'react'
import type { IconType } from 'react-icons'
import {
LuActivity,
LuAppWindow,
LuArchive,
LuAtom,
LuAward,
LuBadgeCheck,
LuBan,
LuBell,
LuBlocks,
LuBookOpen,
LuBot,
LuBoxes,
LuBriefcase,
LuCamera,
LuCalendar,
LuChartPie,
LuChartBar,
LuCheck,
LuCircleCheck,
LuCircle,
LuCircleDot,
LuClock,
LuCloud,
LuCode,
LuCompass,
LuCpu,
LuDatabase,
LuHardDrive,
LuDownload,
LuEye,
LuEyeOff,
LuFile,
LuFileText,
LuFilter,
LuFlag,
LuFolder,
LuFolderOpen,
LuGitBranch,
LuGitCommitHorizontal,
LuGithub,
LuGitPullRequest,
LuGlobe,
LuHammer,
LuHash,
LuHeadphones,
LuHouse,
LuImage,
LuInbox,
LuKeyboard,
LuLayers,
LuLayoutGrid,
LuLink,
LuListChecks,
LuListTodo,
LuLock,
LuMail,
LuMap,
LuMessageCircle,
LuMic,
LuMonitor,
LuMoon,
LuMusic,
LuPackage,
LuPalette,
LuPaperclip,
LuPenLine,
LuPhone,
LuPlay,
LuPlug,
LuPlugZap,
LuPuzzle,
LuRocket,
LuSave,
LuScan,
LuSearch,
LuServer,
LuSettings,
LuSettings2,
LuShare2,
LuShield,
LuShoppingCart,
LuSlidersHorizontal,
LuSparkles,
LuSquareStack,
LuStar,
LuSun,
LuTable,
LuTag,
LuTarget,
LuTerminal,
LuThumbsUp,
LuTimer,
LuTrash2,
LuTrendingUp,
LuUpload,
LuUser,
LuUsers,
LuVideo,
LuWallet,
LuWebhook,
LuWrench,
LuZap
} from 'react-icons/lu'
import { PluginIcon } from './PluginPageFrame'
/** 宿主核准的 lucide 图标白名单(manifest.icon / 胶囊 icon 可填的名字) */
export const PLUGIN_LUCIDE_ICONS = {
Activity: LuActivity,
AppWindow: LuAppWindow,
Archive: LuArchive,
Atom: LuAtom,
Award: LuAward,
BadgeCheck: LuBadgeCheck,
Ban: LuBan,
Bell: LuBell,
Blocks: LuBlocks,
BookOpen: LuBookOpen,
Bot: LuBot,
Boxes: LuBoxes,
Briefcase: LuBriefcase,
Camera: LuCamera,
Calendar: LuCalendar,
ChartPie: LuChartPie,
ChartBar: LuChartBar,
Check: LuCheck,
CircleCheck: LuCircleCheck,
Circle: LuCircle,
CircleDot: LuCircleDot,
Clock: LuClock,
Cloud: LuCloud,
Code: LuCode,
Compass: LuCompass,
Cpu: LuCpu,
Database: LuDatabase,
HardDrive: LuHardDrive,
Download: LuDownload,
Eye: LuEye,
EyeOff: LuEyeOff,
File: LuFile,
FileText: LuFileText,
Filter: LuFilter,
Flag: LuFlag,
Folder: LuFolder,
FolderOpen: LuFolderOpen,
GitBranch: LuGitBranch,
GitCommit: LuGitCommitHorizontal,
Github: LuGithub,
GitPullRequest: LuGitPullRequest,
Globe: LuGlobe,
Hammer: LuHammer,
Hash: LuHash,
Headphones: LuHeadphones,
House: LuHouse,
Image: LuImage,
Inbox: LuInbox,
Keyboard: LuKeyboard,
Layers: LuLayers,
LayoutGrid: LuLayoutGrid,
Link: LuLink,
ListChecks: LuListChecks,
ListTodo: LuListTodo,
Lock: LuLock,
Mail: LuMail,
Map: LuMap,
MessageCircle: LuMessageCircle,
Mic: LuMic,
Monitor: LuMonitor,
Moon: LuMoon,
Music: LuMusic,
Package: LuPackage,
Palette: LuPalette,
Paperclip: LuPaperclip,
PenLine: LuPenLine,
Phone: LuPhone,
Play: LuPlay,
Plug: LuPlug,
PlugZap: LuPlugZap,
Puzzle: LuPuzzle,
Rocket: LuRocket,
Save: LuSave,
Scan: LuScan,
Search: LuSearch,
Server: LuServer,
Settings: LuSettings,
Settings2: LuSettings2,
Share2: LuShare2,
Shield: LuShield,
ShoppingCart: LuShoppingCart,
SlidersHorizontal: LuSlidersHorizontal,
Sparkles: LuSparkles,
SquareStack: LuSquareStack,
Star: LuStar,
Sun: LuSun,
Table: LuTable,
Tag: LuTag,
Target: LuTarget,
Terminal: LuTerminal,
ThumbsUp: LuThumbsUp,
Timer: LuTimer,
Trash2: LuTrash2,
TrendingUp: LuTrendingUp,
Upload: LuUpload,
User: LuUser,
Users: LuUsers,
Video: LuVideo,
Wallet: LuWallet,
Webhook: LuWebhook,
Wrench: LuWrench,
Zap: LuZap
} satisfies Record<string, IconType>
/** kebab/snake/全小写 → PascalCase;已是 Pascal 的名字原样通过 */
function toPascal(name: string): string {
return name
.trim()
.replace(/[-_\s]+(.)/g, (_, c: string) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase())
}
/** 按名字查白名单图标;未命中返回 null */
export function lucideIconByName(name: string): IconType | null {
const key = toPascal(name)
return (PLUGIN_LUCIDE_ICONS as Record<string, IconType>)[key] ?? null
}
/** 图标槽位统一渲染:lucide 名(规范用法)/ 内联 svg(旧兼容)/ 缺省与未知名回落拼图图标 */
export function PluginIconValue({ icon, size = 16, className }: { icon?: string; size?: number; className?: string }): ReactNode {
const v = icon?.trim()
if (v && v.startsWith('<svg')) return <PluginIcon svg={v} size={size} className={className} />
const Icon = (v ? lucideIconByName(v) : null) ?? LuPuzzle
return <Icon size={size} className={className} />
}
@@ -0,0 +1,170 @@
/**
* 外置插件渲染层宿主(entry.renderer 动态装载):
* - 启动即注入 window.__ASO_PLUGIN_HOST__(宿主 React 单实例——插件组件必须用它创建,
* 自带 React 副本会导致 hooks 失效)
* - 插件 renderer 模块源码经 IPC(plugins:readRenderer)取回,以 Blob URL 动态 import
* (dev 页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦),
* 默认导出 mount(ctx) 注册 view/panel 组件;列表/热重载变化即整模块重装
* - 仅开发版装载(与主进程 EXTERNAL_PLUGINS_ENABLED 同开关语义;用户版主进程不下发外置条目)
* - 性能护栏:读源与 import 均限时 8s(挂死的装载不得无限占位),按插件记录
* loading/ready/error 状态供槽位呈现统一等待/失败态;错误隔离到单插件,
* 组件渲染期崩溃由 App 层 PluginErrorBoundary 兜底
*/
import { useEffect, useSyncExternalStore } from 'react'
import type { ComponentType } from 'react'
import * as React from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import { withTimeout } from '@aso/plugin-sdk'
import type { PluginHostGlobal, PluginRendererModule } from '@aso/plugin-sdk/renderer'
import type { PluginInfo } from '@shared/types'
/** 单个 renderer 模块读源/装载限时 */
const RENDERER_LOAD_TIMEOUT_MS = 8_000
/** 插件已注册组件(按槽位;缺省 = 未注册该槽位) */
interface PluginComponents {
view?: ComponentType
panel?: ComponentType
}
const registry = new Map<string, PluginComponents>()
const listeners = new Set<() => void>()
let version = 0
function upsert(pluginId: string, patch: PluginComponents): void {
registry.set(pluginId, { ...registry.get(pluginId), ...patch })
version++
for (const fn of listeners) fn()
}
function remove(pluginId: string): void {
if (!registry.delete(pluginId)) return
version++
for (const fn of listeners) fn()
}
function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
/** 外置插件注册表(version 变化触发重渲染;组件本身从 registry 读取) */
export function useExternalPluginComponents(): Map<string, PluginComponents> {
useSyncExternalStore(subscribe, () => version)
return registry
}
// ---- 装载状态(loading/ready/error,按插件;槽位据此呈现统一等待/失败态) ----
export type PluginRenderStatus = { state: 'loading' } | { state: 'ready' } | { state: 'error'; message: string }
const renderStatus = new Map<string, PluginRenderStatus>()
const statusListeners = new Set<() => void>()
let statusVersion = 0
function setStatus(pluginId: string, status: PluginRenderStatus): void {
renderStatus.set(pluginId, status)
statusVersion++
for (const fn of statusListeners) fn()
}
function deleteStatus(pluginId: string): void {
if (!renderStatus.delete(pluginId)) return
statusVersion++
for (const fn of statusListeners) fn()
}
function subscribeStatus(fn: () => void): () => void {
statusListeners.add(fn)
return () => statusListeners.delete(fn)
}
/** 某插件 renderer 的当前装载状态(未跟踪 = undefined,视为 loading) */
export function usePluginRenderStatus(pluginId: string): PluginRenderStatus | undefined {
useSyncExternalStore(subscribeStatus, () => statusVersion)
return renderStatus.get(pluginId)
}
// ---- 重试:槽位失败态的重试按钮触发整轮重装 ----
let retryTick = 0
/** 重试装载某插件的 renderer(清掉 error 态并重跑装载循环) */
export function retryPluginRenderer(pluginId: string): void {
setStatus(pluginId, { state: 'loading' })
retryTick++
for (const fn of statusListeners) fn()
}
function usePluginRendererRetryTick(): number {
return useSyncExternalStore(subscribeStatus, () => retryTick)
}
// 宿主全局:插件构建期把 react / react/jsx-runtime 别名到这里的实例(见 docs/plugins.md)
;(window as unknown as Record<'__ASO_PLUGIN_HOST__', PluginHostGlobal | undefined>).__ASO_PLUGIN_HOST__ = {
react: React,
jsxRuntime
}
/**
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌 / 重试信号变化即重装。
* 源码经 IPC 取回后以 Blob URL import——dev 下页面源是 http://localhost,
* 直接 import(plugin://) 会被 CORS 拦截;Blob 模块须自包含(相对导入无基路径)。
* 不在列表(禁用/卸载/关闭开关)或主进程侧报错的插件被移除,其组件随之卸载。
*/
export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: number): void {
const retryToken = usePluginRendererRetryTick()
useEffect(() => {
if (!import.meta.env.DEV) return
let cancelled = false
const load = async (): Promise<void> => {
const keep = new Set<string>()
for (const plugin of plugins) {
const file = plugin.manifest.entry?.renderer
if (!plugin.enabled || plugin.source !== 'external' || plugin.error || !file) continue
const id = plugin.manifest.id
keep.add(id)
setStatus(id, { state: 'loading' })
try {
const code = await withTimeout(window.api.pluginsReadRenderer(id), RENDERER_LOAD_TIMEOUT_MS, `读取 ${id} renderer 源码`)
if (cancelled) return
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
try {
const mod = (await withTimeout(
import(/* @vite-ignore */ url),
RENDERER_LOAD_TIMEOUT_MS,
`装载 ${id} renderer 模块`
)) as { default?: unknown }
if (cancelled) return
const mount = mod.default
if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数')
;(mount as PluginRendererModule)({
manifest: plugin.manifest,
react: React,
registerView: (component) => upsert(id, { view: component }),
registerPanel: (component) => upsert(id, { panel: component })
})
setStatus(id, { state: 'ready' })
} finally {
URL.revokeObjectURL(url)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[plugins] ${id} renderer 装载失败:`, err)
setStatus(id, { state: 'error', message })
remove(id)
}
}
for (const id of [...registry.keys()]) {
if (!keep.has(id)) {
remove(id)
deleteStatus(id)
}
}
}
void load()
return () => {
cancelled = true
}
}, [plugins, reloadToken, retryToken])
}
@@ -53,7 +53,7 @@ export function usePluginsUiState(): PluginsUiState {
/** 仅返回已启用且有侧栏入口的插件(入口排序:order 升序,其次 id) */ /** 仅返回已启用且有侧栏入口的插件(入口排序:order 升序,其次 id) */
export function sidebarPlugins(plugins: PluginInfo[]): PluginInfo[] { export function sidebarPlugins(plugins: PluginInfo[]): PluginInfo[] {
return plugins return plugins
.filter((p) => p.enabled && (p.hasComponent || p.hasPage)) .filter((p) => p.enabled && (p.hasComponent || p.hasPage || p.hasRenderer))
.sort( .sort(
(a, b) => (a, b) =>
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) || (a.manifest.order ?? 100) - (b.manifest.order ?? 100) ||
@@ -1,115 +1,127 @@
/* Beautiful UI foundation — design tokens 按项目设计规范(OpenAI 浅色体系)重映射。 /* Beautiful UI foundation — design tokens 与 beautifului.dev 原版 foundation.css
* 规范要点:浅色界面;accent #8e8ea0(muted purple-gray);正文近黑;8px 间距网格; * 对齐(MIT):冷调近白画布、白色卡片、hairline 边框、「ring + 柔和分层阴影」
* 圆角统一 5px(圆形仅限 icon 按钮/状态点/头像);动效统一 400ms + ease; * 的层级体系、蓝色 accent、克制语义色。亮暗两套完整 token,切换 = 一次重绘。
* 平面层级:surface 之间用 hairline 边框分层,仅浮层(modal/popover/审批卡)允许一层中性软阴影。 * 圆角体系:chip 6 / control 8 / card 10 / window 14 / pill 999。
* 层级语义:--accent 负责焦点环/选区/选中态/装饰 tint;--accent-ink(加深版)负责 * 动效:强缓动曲线(--ease-out-strong 等)+ pill 控件 150ms ease-out。 */
* 链接与可交互文本(保证 AA 对比度);--ink-3 是弱化文本的 AA 安全灰。 */
@import "shadow-plugin/unprefixed"; @import "shadow-plugin/unprefixed";
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
/* ────────────────────────────────────────────────────────── /* ──────────────────────────────────────────────────────────
* OpenAI light — design tokens * Beautiful UI — design tokens
* Cool near-white canvas, white cards, hairline borders, layered
* single-digit-opacity shadows, a neutral ink ramp, and semantic
* color used sparingly as a condiment.
* ────────────────────────────────────────────────────────── */ * ────────────────────────────────────────────────────────── */
:root { :root {
/* motion — 规范统一值 */ /* motion — pill 控件/悬停反馈走 150ms ease-out;大位移动效用强曲线变量 */
--duration-fast: 400ms; --duration-fast: 150ms;
--duration-base: 400ms; --duration-base: 220ms;
--duration-slow: 400ms; --duration-slow: 360ms;
--ease-standard: ease; --ease-standard: ease-out;
/* surfaces — 中性灰阶,白底为主 */ /* surfaces — cool, blue-tinted neutrals */
--page: #ffffff; --page: oklch(0.985 0.001 286.376);
--canvas: #ffffff; --canvas: oklch(0.961 0.002 247.84);
--surface: #ffffff; --surface: oklch(1 0 0);
--inset: #f4f4f5; --inset: oklch(0.979 0.002 247.839);
--hover: #f0f0f1; --hover: oklch(0.97 0.002 247.839);
--hover-2: #e6e6e8; --hover-2: oklch(0.933 0.003 247.86);
/* ink ramp — 正文近黑(规范 #000 的 a11y 修正版),弱化层为 AA 安全灰 */ /* ink ramp */
--ink: #0d0d0d; --ink: oklch(0.247 0.006 258.361);
--ink-2: #5d5d5d; --ink-2: oklch(0.506 0.01 264.477);
--ink-3: #75757f; --ink-3: oklch(0.695 0.009 264.505);
/* borders — 实色 hairline,不用 alpha */ /* borders — solid and crisp, not alpha */
--line: #ececec; --line: oklch(0.946 0.003 264.542);
--line-strong: #dcdce0; --line-strong: oklch(0.912 0.005 258.326);
--line-soft: #f4f4f5; --line-soft: oklch(0.966 0.002 264.542);
--grid-line: color-mix(in srgb, var(--line) 78%, transparent); --grid-line: color-mix(in srgb, var(--line) 78%, transparent);
--field: #f4f4f5; --field: oklch(0.961 0.001 286.375);
--stripe: rgba(13, 13, 13, 0.04); --stripe: oklch(0.405 0 0 / 0.075);
--stripe-bg: #f7f7f8; --stripe-bg: oklch(0.97 0 0);
/* accent — 规范主色 #8e8ea0 及其加深可交互版 */ /* accent */
--accent: #8e8ea0; --accent: oklch(0.626 0.205 254.947);
--accent-ink: #707084; --accent-ink: oklch(0.556 0.187 255.617);
--accent-tint: rgba(142, 142, 160, 0.12); --accent-tint: oklch(0.96 0.019 252.878);
/* semantic — 唯一允许的彩色例外(状态语义),已降饱和融入中性界面 */ /* semantic */
--green: #2e7d5b; --green: oklch(0.603 0.155 150.883);
--green-tint: #e9f3ee; --green-tint: oklch(0.958 0.017 159.118);
--orange: #b45309; --orange: oklch(0.689 0.179 49.902);
--orange-tint: #faf1e3; --orange-tint: oklch(0.964 0.021 67.581);
--red: #b91c1c; --red: oklch(0.621 0.192 23.042);
--red-tint: #fbecec; --red-tint: oklch(0.956 0.017 17.462);
/* chart overlays */ /* chart overlays — neutral system colors with enough contrast on either theme */
--tooltip-bg: #2a2a2e; --tooltip-bg: oklch(0.272 0.008 264.435);
--tooltip-fg: #f7f7f8; --tooltip-fg: oklch(0.976 0.002 247.839);
--tooltip-muted: #a5a5af; --tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: #454549; --tooltip-border: oklch(0.356 0.007 264.474);
/* shadows — 平面优先:hairline 分层;仅浮层保留一层中性软阴影(文档化例外) */ /* shadows — solid hairline ring + shadow-plugin's smooth layered stacks
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
* consistently across the app. */
--shadow-hairline: 0 0 0 1px var(--line); --shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong); --shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
--shadow-card: 0 0 0 1px var(--line); --shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
--shadow-raised: 0 0 0 1px var(--line-strong); --shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 12px 32px rgba(13, 13, 13, 0.08); --shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
--shadow-inset-field: inset 0 0 0 1px var(--line-strong); --shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
} }
.dark { .dark {
--page: #212121; --page: oklch(0.209 0.004 264.477);
--canvas: #212121; --canvas: oklch(0.231 0.004 264.487);
--surface: #2f2f2f; --surface: oklch(0.26 0.006 271.191);
--inset: #181818; --inset: oklch(0.243 0.004 264.492);
--hover: #2f2f2f; --hover: oklch(0.289 0.006 271.22);
--hover-2: #424242; --hover-2: oklch(0.318 0.007 274.747);
--ink: #ececec; --ink: oklch(0.964 0.002 247.839);
--ink-2: #b4b4b4; --ink-2: oklch(0.731 0.008 260.731);
--ink-3: #8e8ea0; --ink-3: oklch(0.541 0.01 264.484);
--line: #383838; --line: oklch(0.308 0.006 258.354);
--line-strong: #4a4a4a; --line-strong: oklch(0.356 0.007 264.474);
--line-soft: #2a2a2a; --line-soft: oklch(0.278 0.006 258.354);
--field: #2f2f2f; --field: oklch(0.293 0.006 271.223);
--stripe: rgba(255, 255, 255, 0.05); --stripe: oklch(1 0 0 / 0.055);
--stripe-bg: #262626; --stripe-bg: oklch(0.226 0.004 264.485);
--accent: #a3a3b5; --accent: oklch(0.68 0.173 253.301);
--accent-ink: #bcbccc; --accent-ink: oklch(0.788 0.113 248.33);
--accent-tint: rgba(142, 142, 160, 0.18); --accent-tint: oklch(0.68 0.173 253.301 / 0.16);
--green: #6fbf95; --green: oklch(0.705 0.154 153.814);
--green-tint: rgba(111, 191, 149, 0.15); --green-tint: oklch(0.705 0.154 153.814 / 0.14);
--orange: #d19a55; --orange: oklch(0.746 0.156 55.642);
--orange-tint: rgba(209, 154, 85, 0.15); --orange-tint: oklch(0.746 0.156 55.642 / 0.14);
--red: #e07a70; --red: oklch(0.666 0.18 21.433);
--red-tint: rgba(224, 122, 112, 0.15); --red-tint: oklch(0.666 0.18 21.433 / 0.14);
--tooltip-bg: #171717; --tooltip-bg: oklch(0.182 0.004 264.459);
--tooltip-fg: #ececec; --tooltip-fg: oklch(0.964 0.002 247.839);
--tooltip-muted: #8e8ea0; --tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: #383838; --tooltip-border: oklch(0.308 0.006 258.354);
--shadow-hairline: 0 0 0 1px var(--line); --shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong); /* Elevation rings go to a subtle *light* hairline in dark mode: the ring
--shadow-card: 0 0 0 1px var(--line); * paints over the surface behind the element, so a low-alpha white edge
--shadow-raised: 0 0 0 1px var(--line-strong); * dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 16px 40px rgba(0, 0, 0, 0.45); * graduated by how far the surface floats. */
--shadow-inset-field: inset 0 0 0 1px var(--line-strong); --shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
--shadow-card:
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
0 2px 6px oklch(0 0 0 / 0.2);
--shadow-raised:
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
--shadow-overlay:
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
} }
@theme inline { @theme inline {
@@ -146,20 +158,20 @@
/* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */ /* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */
/* radii — 规范统一 5px;rounded-full 仅限 icon 圆钮/状态点/头像 */ /* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
--radius-chip: 5px; --radius-chip: 6px;
--radius-control: 5px; --radius-control: 8px;
--radius-card: 5px; --radius-card: 10px;
--radius-window: 5px; --radius-window: 14px;
/* easing — 规范统一 ease;保留旧变量名以兼容 primitives */ /* easing — strong curves; built-ins are too weak */
--ease-out-strong: ease; --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-strong: ease; --ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
--ease-link: ease; --ease-link: cubic-bezier(0.16, 1, 0.3, 1);
/* motion — Tailwind transition-* 工具类的全局默认:400ms + ease */ /* motion — pill 控件/悬停反馈的全局默认:150ms ease-out(原版手感) */
--default-transition-duration: 400ms; --default-transition-duration: 150ms;
--default-transition-timing-function: ease; --default-transition-timing-function: ease-out;
} }
/* ── base ─────────────────────────────────────────────── */ /* ── base ─────────────────────────────────────────────── */
@@ -170,6 +182,43 @@ html {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
body {
color: var(--ink);
font-feature-settings: "cv11", "ss01";
letter-spacing: -0.01em;
}
::selection {
background: var(--accent-tint);
color: var(--ink);
}
/* headings balance; descriptions avoid orphans */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
/* underlines clear descenders */
a {
text-underline-position: from-font;
text-decoration-skip-ink: auto;
}
/* theme switches swap every token at once — freeze transitions so the flip
* is one clean repaint instead of hundreds of mismatched color fades */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
/* Tailwind v4 resets buttons to cursor:default — anything /* Tailwind v4 resets buttons to cursor:default — anything
* clickable must invite the click */ * clickable must invite the click */
button:not(:disabled), button:not(:disabled),
@@ -207,7 +256,7 @@ select:focus-visible,
background: currentColor; background: currentColor;
transform: scaleX(0); transform: scaleX(0);
transform-origin: left center; transform-origin: left center;
transition: transform var(--duration-base) var(--ease-standard); transition: transform 280ms var(--ease-link);
} }
a:hover .animated-underline::after, a:hover .animated-underline::after,
@@ -216,23 +265,24 @@ a:focus-visible .animated-underline::after {
} }
/* ── primitive spacing system ───────────────────────────── /* ── primitive spacing system ─────────────────────────────
* 8px 网格上的共享间距。 * Shared optical offsets for small cards. Use these
* instead of hand-tuning 10/12/14px per component.
*/ */
.primitive-card-pad { .primitive-card-pad {
padding: 8px; padding: 12px;
} }
.primitive-card-bar { .primitive-card-bar {
padding: 8px; padding: 10px 12px;
} }
.primitive-card-footer { .primitive-card-footer {
padding: 8px; padding: 10px;
} }
.primitive-table-cell { .primitive-table-cell {
padding: 8px; padding: 10px 12px;
} }
.primitive-icon-button { .primitive-icon-button {
@@ -244,83 +294,6 @@ a:focus-visible .animated-underline::after {
border-radius: var(--radius-control); border-radius: var(--radius-control);
} }
/* ── sidebar nav(beautifului.dev Sidebar Nav,官方组件级 CSS)──
* 收起态靠行宽切换保证图标对齐:.sidebar-row 固定 208px,
* data-sidebar-collapsed 时收为 36px(size-5 图标 + px-2 ×2),
* 恰好落在 52px 轨道正中;glide 高亮层同步收窄。
* --sidebar-copy-duration / --sidebar-copy-offset / --sidebar-easing
* 由 <aside data-sidebar-collapsed> 上注入。
*/
.sidebar-row {
width: 208px;
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: var(--sidebar-easing, ease);
}
.sidebar-copy {
opacity: 1;
transform: translateX(0);
transition:
opacity var(--sidebar-copy-duration, 180ms) ease-out,
transform var(--sidebar-copy-duration, 180ms) var(--sidebar-easing, ease);
}
.sidebar-logo,
.sidebar-collapse-control,
.sidebar-expand-control {
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: ease-out;
}
.sidebar-expand-control {
pointer-events: none;
opacity: 0;
}
.sidebar-glide-highlight {
right: 8px;
left: 8px;
}
[data-sidebar-collapsed='true'] .sidebar-row {
width: 36px;
}
[data-sidebar-collapsed='true'] .sidebar-copy {
pointer-events: none;
opacity: 0;
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
}
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
pointer-events: none;
}
[data-sidebar-collapsed='true'] .sidebar-logo,
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
pointer-events: none;
opacity: 0;
}
[data-sidebar-collapsed='true'] .sidebar-expand-control {
pointer-events: auto;
opacity: 1;
}
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
right: auto;
width: 36px;
}
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
/* ── shared keyframes ─────────────────────────────────── */ /* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text { @keyframes shimmer-text {
@@ -391,3 +364,61 @@ a:focus-visible .animated-underline::after {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* ── 项目机制类:侧栏折叠(对应 SidebarNav 原版 CSS,含应用内专用入口) ── */
.sidebar-logo,
.sidebar-collapse-control,
.sidebar-expand-control {
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: ease-out;
}
.sidebar-expand-control {
pointer-events: none;
opacity: 0;
}
.sidebar-glide-highlight {
right: 8px;
left: 8px;
}
[data-sidebar-collapsed='true'] .sidebar-row {
width: 36px;
}
[data-sidebar-collapsed='true'] .sidebar-copy {
pointer-events: none;
opacity: 0;
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
}
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
pointer-events: none;
}
[data-sidebar-collapsed='true'] .sidebar-logo,
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
pointer-events: none;
opacity: 0;
}
[data-sidebar-collapsed='true'] .sidebar-expand-control {
pointer-events: auto;
opacity: 1;
}
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
right: auto;
width: 36px;
}
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
@@ -15,7 +15,7 @@ body,
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(142, 142, 160, 0.35) transparent; scrollbar-color: color-mix(in srgb, var(--ink-3) 45%, transparent) transparent;
} }
*::-webkit-scrollbar { *::-webkit-scrollbar {
@@ -24,36 +24,23 @@ body,
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background: rgba(142, 142, 160, 0.35); background: color-mix(in srgb, var(--ink-3) 45%, transparent);
border-radius: 4px; border-radius: 4px;
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
background: rgba(142, 142, 160, 0.55); background: color-mix(in srgb, var(--ink-3) 70%, transparent);
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::selection { /* ::selection 在 beautifului.css 基座(accent-tint + ink) */
background: rgba(142, 142, 160, 0.28);
}
/* 流式输出光标 */
@keyframes caret-blink {
0%,
60% {
opacity: 1;
}
61%,
100% {
opacity: 0;
}
}
/* 流式输出光标:以 ::after 附加到流式正文最后一个文本块的最后一行文字之后 /* 流式输出光标:以 ::after 附加到流式正文最后一个文本块的最后一行文字之后
(inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失 */ (inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失。
对齐 beautifului StreamText:流式期间光标常亮(不闪烁)。 */
.streaming-body[data-streaming='true'] > *:last-child > :is(p, h1, h2, h3, h4, h5, h6):last-child::after, .streaming-body[data-streaming='true'] > *:last-child > :is(p, h1, h2, h3, h4, h5, h6):last-child::after,
.streaming-body[data-streaming='true'] > *:last-child > :is(ul, ol):last-child > li:last-child::after { .streaming-body[data-streaming='true'] > *:last-child > :is(ul, ol):last-child > li:last-child::after {
content: ''; content: '';
@@ -64,7 +51,6 @@ body,
vertical-align: -0.15em; vertical-align: -0.15em;
background: var(--ink); background: var(--ink);
border-radius: 1px; border-radius: 1px;
animation: caret-blink 0.9s steps(1) infinite;
} }
/* 禁用 textarea 的默认 resize 与滚动条闪烁 */ /* 禁用 textarea 的默认 resize 与滚动条闪烁 */
+46 -37
View File
@@ -149,6 +149,10 @@ export interface AppSettings {
themeColor?: string themeColor?: string
/** 深浅色模式(缺省 system 跟随系统;经 Electron nativeTheme 生效) */ /** 深浅色模式(缺省 system 跟随系统;经 Electron nativeTheme 生效) */
themeMode?: ThemeMode themeMode?: ThemeMode
/** 已禁用的技能名清单(技能按 name 加载去重,name 即唯一标识;加载阶段整体过滤) */
disabledSkills?: string[]
/** 已禁用的命令(prompt 模板)名清单 */
disabledCommands?: string[]
} }
/** 深浅色模式:light / dark / system(跟随系统) */ /** 深浅色模式:light / dark / system(跟随系统) */
@@ -193,6 +197,8 @@ export interface SkillDetail {
scope: ResourceScope scope: ResourceScope
/** true = 不进入系统提示词,仅可通过 /skill:name 显式调用 */ /** true = 不进入系统提示词,仅可通过 /skill:name 显式调用 */
disableModelInvocation: boolean disableModelInvocation: boolean
/** true = 已被用户禁用(加载阶段过滤,不进会话) */
disabled: boolean
} }
/** 设置页命令条目(pi prompt template,含来源文件路径) */ /** 设置页命令条目(pi prompt template,含来源文件路径) */
@@ -203,6 +209,8 @@ export interface CommandDetail {
argumentHint?: string argumentHint?: string
filePath: string filePath: string
scope: ResourceScope scope: ResourceScope
/** true = 已被用户禁用(加载阶段过滤,不进会话) */
disabled: boolean
} }
/** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */ /** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */
@@ -263,16 +271,6 @@ export interface BrowserClosedTab {
url: string url: string
} }
/** 已导入的本地浏览器配置摘要(面板展示用) */
export interface BrowserImportInfo {
browser: string
profile: string
at: string
cookies: boolean
localStorage: boolean
note?: string
}
/** 浏览器面板完整状态(事件与查询共用同一结构) */ /** 浏览器面板完整状态(事件与查询共用同一结构) */
export interface BrowserUiState { export interface BrowserUiState {
/** 是否存在受管浏览器会话(有标签页即视为打开) */ /** 是否存在受管浏览器会话(有标签页即视为打开) */
@@ -285,36 +283,22 @@ export interface BrowserUiState {
recentClosed: BrowserClosedTab[] recentClosed: BrowserClosedTab[]
/** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */ /** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */
trace: string | null trace: string | null
imported?: BrowserImportInfo | null /** 当前代理设置(面板展示与编辑;变更即广播) */
proxy: BrowserProxySettings
}
/** 受管浏览器代理设置(作用于 persist:aso-browser 分区;保存后立即生效) */
export interface BrowserProxySettings {
/** system=跟随系统(Electron 默认)| direct=直连 | fixed=手动规则 */
mode: 'system' | 'direct' | 'fixed'
/** fixed 模式:Chromium proxyRules 语法——host:port,多协议用分号分隔,可选 scheme= 前缀(如 http=127.0.0.1:7890;https=127.0.0.1:7890) */
rules?: string
/** 绕过列表(逗号分隔:<local>、*.example.com 等;Chromium bypass 语法) */
bypass?: string
} }
export type BrowserUiEvent = { type: 'state'; state: BrowserUiState } export type BrowserUiEvent = { type: 'state'; state: BrowserUiState }
/** 本地浏览器(Chromium 系)下的一个 profile */
export interface BrowserProfileMeta {
/** profile 目录名(Default / Profile 1 …) */
dir: string
/** 展示名(Preferences 里的名称或目录名) */
name: string
}
/** 检测到的可导入浏览器来源 */
export interface BrowserSource {
id: string
name: string
userDataDir: string
profiles: BrowserProfileMeta[]
}
/** 导入结果(主进程返回;cookieNote 说明跳过原因) */
export interface BrowserImportResult {
ok: boolean
cookies: boolean
localStorage: boolean
cookieNote?: string
message: string
}
export type AgentUiEvent = export type AgentUiEvent =
| { type: 'session_ready'; state: SessionState } | { type: 'session_ready'; state: SessionState }
| { type: 'session_error'; message: string } | { type: 'session_error'; message: string }
@@ -353,12 +337,25 @@ export type AgentUiEvent =
// ===== 插件系统(契约类型来自 @aso/plugin-sdk) ===== // ===== 插件系统(契约类型来自 @aso/plugin-sdk) =====
import type { PluginManifest, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk' import type { PluginManifest, PluginStorageSchema, StatusItem, ToolbarButtonDef } from '@aso/plugin-sdk'
/** 依赖状态(管理页 Tooltip 数据源):本插件 manifest 声明的每个依赖的实时可用性 */
export interface PluginDepStatus {
id: string
/** 声明的 semver 范围(^x.y.z / ~x.y.z / >=x.y.z / 精确 / *) */
range: string
/** true=必需(缺失/停用/版本不符 → 本插件激活失败);false=可选(只降级) */
required: boolean
/** ok=可用;missing=未安装;disabled=已停用;version=版本不符;error=自身装载失败 */
status: 'ok' | 'missing' | 'disabled' | 'version' | 'error'
}
/** 插件条目(管理页/侧栏入口数据源) */ /** 插件条目(管理页/侧栏入口数据源) */
export interface PluginInfo { export interface PluginInfo {
manifest: PluginManifest manifest: PluginManifest
source: 'builtin' | 'external' source: 'builtin' | 'external'
/** 外置来源目录类别(仅 external):user=应用外置目录 / project=项目 .pi/extensions / global=全局扩展目录 */
origin?: 'user' | 'project' | 'global'
enabled: boolean enabled: boolean
/** 有主进程工厂(pi 工具/事件/内联 UI 注册能力) */ /** 有主进程工厂(pi 工具/事件/内联 UI 注册能力) */
hasMain: boolean hasMain: boolean
@@ -366,6 +363,16 @@ export interface PluginInfo {
hasPage: boolean hasPage: boolean
/** 有宿主树组件(仅内置;外置遮蔽需 replacesNative 声明) */ /** 有宿主树组件(仅内置;外置遮蔽需 replacesNative 声明) */
hasComponent: boolean hasComponent: boolean
/** 有渲染层模块(entry.renderer,宿主动态 import 后注册宿主内 React 组件) */
hasRenderer: boolean
/** 有设置页可进:已声明设置表单(api.ui.registerSettings),或内置插件带宿主专属设置页(如浏览器) */
hasSettings: boolean
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
settingsNav: boolean
/** 依赖清单及实时状态(manifest.dependencies + optionalDependencies 求值;无声明则空数组) */
dependencies: PluginDepStatus[]
/** 已声明的存储 schema(api.storage.declareSchema;未声明则缺省) */
storageSchema?: PluginStorageSchema
/** 装载/激活错误(不影响其他插件) */ /** 装载/激活错误(不影响其他插件) */
error?: string error?: string
} }
@@ -375,6 +382,8 @@ export interface PluginStatusEntry {
pluginId: string pluginId: string
itemId: string itemId: string
item: StatusItem item: StatusItem
/** 最近一次写入时间(宿主记录;悬浮胶囊据此展示"插件最新消息") */
updatedAt: number
} }
/** 工具栏按钮条目 */ /** 工具栏按钮条目 */
+120 -14
View File
@@ -1,7 +1,9 @@
# 插件系统(harness 插件契约) # 插件系统(harness 插件契约)
> **教程入口**:[docs/plugins/README.md](./plugins/README.md) —— L0 第一个插件 → L1 工具命令 → L2 设置 → L3 UI 槽位 → L4 React 组件 → L5 自诊断 → L8 依赖互调 → L9 存储;视觉规范 [DESIGN.md](./plugins/DESIGN.md);脚手架 [packages/plugin-template](../packages/plugin-template/)。本文件是契约全集(参考手册)。
>
> 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap) > 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap)
> 契约版本:`HARNESS_CONTRACT_VERSION = 0.1.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载) > 契约版本:`HARNESS_CONTRACT_VERSION = 0.3.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载。0.3.0 新增:依赖声明/服务互调/SQLite 存储)
## 两层能力模型 ## 两层能力模型
@@ -25,15 +27,19 @@
"version": "1.2.0", // semver(x.y.z) "version": "1.2.0", // semver(x.y.z)
"minContract": "0.1.0", "minContract": "0.1.0",
"description": "…", "description": "…",
"icon": "<svg…/>", // 侧栏/面板图标(svg 字符串;宿主清除事件属性与 script) "icon": "Globe", // 侧栏/面板/胶囊图标:lucide 图标名(白名单见 DESIGN.md;kebab 亦可);兼容旧 svg 字符串;未知名回落拼图图标
"order": 10, // 侧栏排序(只在插件间;核心入口恒在前) "order": 10, // 侧栏排序(只在插件间;核心入口恒在前)
"panel": true, // 在右侧 SidePanel 出现一个 tab "panel": true, // 在右侧 SidePanel 出现一个 tab
"entry": { "page": "index.html", "main": "main.mjs" }, // 内置插件省略 entry "entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry
"replacesNative": false, "replacesNative": false,
"dependencies": { "fetch-tools": "^1.0.0" }, // 必需依赖:id → semver 范围(* ^ ~ >= 精确);缺失/停用/版本不符 → 激活失败并提示
"optionalDependencies": { "embed-store": "~2.1.0" }, // 可选依赖:缺失只降级(requirePlugin 返回 undefined)
"updateURL": "https://…/index.json" "updateURL": "https://…/index.json"
} }
``` ```
外置插件源(`EXTERNAL_PLUGINS_ENABLED` 开发版开/用户版关,`ASO_EXTERNAL_PLUGINS=1` 豁免):`userData/plugins/<id>/` > 项目 `.pi/extensions/<id>/` > 全局 `~/.pi/agent/extensions/<id>/`。渲染层模块文件**不要命名为 index.ts/index.js**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载。
## 主进程插件契约(`@aso/plugin-sdk` 主入口) ## 主进程插件契约(`@aso/plugin-sdk` 主入口)
```ts ```ts
@@ -49,14 +55,23 @@ export default definePlugin((api) => {
api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } }) api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } })
api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页 api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页
// 插件私有 KV(userData/aso-plugin-storage.json,按 pluginId 隔离) // 插件私有存储(SQLite,userData/plugin-data/<id>/data.db;API 全 async,宿主共享 worker 执行)
api.storage.set('lastQuery', 'xxx') await api.storage.declareSchema({ tables: [{ name: 'notes', description: '…', columns: [{ name: 'id', type: 'TEXT', description: '…' }] }] })
await api.storage.kv.set('lastQuery', 'xxx') // KV(宿主管理的 kv 表,JSON 编码)
const rows = await api.storage.db.query('SELECT * FROM notes LIMIT 10')
api.storage.dir() // 自由文件目录
// 跨插件互调(依赖声明即访问授权;拓扑波次保证依赖先激活)
api.service('fetch', (url: string) => fetch(url).then((r) => r.text())) // 暴露服务
const deps = api.requirePlugin('fetch-tools') // 未声明依赖 → 抛错;可选未就绪 → undefined
return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ } return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ }
}) })
``` ```
实现:harness 在 pi `extensionFactories` 挂**一个聚合 extension**,工厂内创建真实 `ExtensionAPI` 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。 实现:harness 在 pi `extensionFactories` 挂**一个聚合 extension**,工厂内创建真实 `ExtensionAPI` 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。激活顺序 = **必需依赖图的拓扑波次**(依赖先、依赖者后,同波并行;环整环报错;异步工厂限时 10s)。
依赖与存储细则:见 [L8 依赖与互调](./plugins/10-dependencies.md)、[L9 存储](./plugins/11-storage.md)。
## 插件页(iframe)契约(`@aso/plugin-sdk/client`) ## 插件页(iframe)契约(`@aso/plugin-sdk/client`)
@@ -75,18 +90,101 @@ useHarnessTheme('hotwords') // { dark, accent }(深浅色/acce
- 安全:宿主校验 `event.source === iframe.contentWindow` **且** `event.origin === 'plugin://<id>'`;sandbox 无 `allow-top-navigation`;iframe 页**无** window.api 直达 - 安全:宿主校验 `event.source === iframe.contentWindow` **且** `event.origin === 'plugin://<id>'`;sandbox 无 `allow-top-navigation`;iframe 页**无** window.api 直达
- 主题连续性:插件页 `<link rel="stylesheet" href="peer://assets/tokens.css">`(宿主 beautifului.css 的 token 表,深浅色由 `.dark` 类 + 下发的 accent 生效);建议插件自带 Tailwind 按同 token 编译 - 主题连续性:插件页 `<link rel="stylesheet" href="peer://assets/tokens.css">`(宿主 beautifului.css 的 token 表,深浅色由 `.dark` 类 + 下发的 accent 生效);建议插件自带 Tailwind 按同 token 编译
## 渲染层模块契约(`@aso/plugin-sdk/renderer`)
外置插件以 React 组件形态进入宿主界面(与内置同槽位),不再是 iframe 物理隔离:
```jsonc
// manifest.json
{ "id": "demo-ui", "panel": true, "entry": { "renderer": "renderer.mjs" } }
```
```js
// renderer.mjs —— 宿主渲染层动态 import(plugin://<id>/renderer.mjs) 后调用默认导出
export default function mount(ctx) {
ctx.registerView(MyViewComponent) // 整页视图(侧栏入口 → 主内容区)
ctx.registerPanel(MyPanelComponent) // 面板 tab 内容(需 panel: true)
}
```
- **单实例约束**:组件必须用 `ctx.react`(宿主 React,即 `window.__ASO_PLUGIN_HOST__.react`)创建;插件自带 React 副本会导致 hooks 全部失效
- JSX 工程构建配方(esbuild,react 别名到宿主实例的 shim):
```sh
esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
--jsx=automatic \
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
```
```js
// shims/react.js —— 构建期别名目标
const R = window.__ASO_PLUGIN_HOST__.react
export default R
export const { useState, useEffect, useRef, useMemo, useCallback, createContext,
useContext, Fragment, memo, forwardRef, createElement } = R
```
```js
// shims/jsx-runtime.js
const J = window.__ASO_PLUGIN_HOST__.jsxRuntime
export const { jsx, jsxs, Fragment } = J
```
- 装载时机与热重载:插件列表变化(扫描/启停/`plugins:reload`)即按 `?t=` cache-bust 重装;被禁用/移除的插件注册随之清除
- 门控:仅开发版装载(`import.meta.env.DEV` + 主进程 `EXTERNAL_PLUGINS_ENABLED`)
- 性能护栏:读源与 import 各限时 8s,超时/失败由宿主槽位呈现统一等待/失败态(ErrorState + 重试,见 `plugins/renderer-host`);渲染期崩溃由 PluginErrorBoundary 兜底
## 公共 UI 组件库与异步工具(`@aso/plugin-sdk/ui` · `withTimeout`/`withRetry`)
- **`./ui` 子入口**:宿主视觉规范的代码化——`Button`/`Input`/`Select`/`Switch`/`Card`/`SettingRow`/`Badge`
+ 三态组件 `LoadingState`/`ErrorState`/`EmptyState`/`Spinner`。仅 renderer 组件槽位可用
(渲染在宿主 DOM,复用宿主样式表);组件目录与三态铁律见 [docs/plugins/07-ui-components.md](./plugins/07-ui-components.md)
- **异步工具(主入口导出)**:`withTimeout(promise, ms, label?)`、`withRetry(fn, { retries, delayMs, timeoutMs, onRetry })`、
`TimeoutError`——插件内一切可能挂起的异步(网络/子进程/文件轮询)必须自带超时与重试
- **宿主兜底**:插件激活限时 10s(异步工厂超时/reject 即标记插件失败,不拖垮 runtime 重建);
renderer 装载限时 8s
- **机器关卡**:`pnpm plugin:check <插件目录>` 校验契约完整性、同步 IO、console.*、
React 单实例、hex 色值、三态使用等(error 级不通过即 exit 1),规则见 [docs/plugins/08-performance.md](./plugins/08-performance.md)
## 插件设置(声明式 schema,`api.ui.registerSettings`)
插件在 main.mjs 里声明配置表单,宿主负责渲染/持久化/回推——schema 是**纯数据**(不可含函数/React 元素):
```js
// main.mjs
api.ui.registerSettings({
title: '我的插件设置',
items: [
{ type: 'string', key: 'token', label: 'API Token', password: true },
{ type: 'boolean', key: 'enabled', label: '启用增强', default: true },
{ type: 'select', key: 'mode', label: '模式', options: [{ value: 'fast', label: '快速' }], default: 'fast' },
{ type: 'path', key: 'dir', label: '数据目录', mode: 'directory' },
{ type: 'image', key: 'logo', label: 'Logo' },
{ type: 'button', key: 'test', label: '测试连接', variant: 'primary' }
]
})
api.ui.getSettingValues() // 当前值(schema default 已合并)
api.ui.onSettingsChange((v) => {}) // 用户修改设置后实时推送
api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮项 key)
```
- **标准控件**(`PluginSettingItem`,按 `type` 判别):`string`(含 password)/ `text` 多行 / `number` / `boolean`(switch)/ `select` / `multiSelect` / `slider` / `color` / `path`(file|directory)/ `image`(选择+缩略图)/ `button`(动作)/ `info` / `divider`
- **持久化**:值存 `userData/aso-plugin-values.json`(按插件隔离);`getSettingValues` 合并 schema 默认值
- **一级入口**:有配置项(items 非空)的插件,在插件页行内出现「一级入口」开关;打开后设置页左侧「Agent 能力」组追加以插件名命名的导航项,点击进二级配置页。items 为空/未声明 → 无开关、无导航项
- 值变更经 `plugins:settingsValues` 广播:设置页与插件主进程两侧同步
## UI 槽位总览(对位 pi 的 ctx.ui) ## UI 槽位总览(对位 pi 的 ctx.ui)
| 槽位 | 机制 | 状态 | | 槽位 | 机制 | 状态 |
|---|---|---| |---|---|---|
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ | | 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
| 插件整页 Main | 内置组件级 / 外置 iframe;聊天列 display:none 保活 | ✅ | | 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ |
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件或 iframe) | ✅ | | 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ |
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见 | ✅ | | 状态胶囊(B2+B3) | `setStatusItem` 数据驱动,**悬浮胶囊**挂 main 区右上角(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),展开面板头即插件名、`detail` 分组为手风琴(默认收起显示 标题+trailing 徽标,点击展开行)、最大高度随内容自适应、行尾彩色数值与行内深链 | ✅ |
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ | | 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 | | 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 | | 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
| 聊天区浮动胶囊(B3) | 与 B2 同源数据,折叠/展开双态 | ⏳ 下版本 |
## 交互基线 ## 交互基线
@@ -99,12 +197,15 @@ useHarnessTheme('hotwords') // { dark, accent }(深浅色/acce
## 安全清单 ## 安全清单
- `plugin://` 特权协议:路径解析后必须落在该插件目录内(防穿越);`peer://` 仅暴露白名单资产(tokens.css) - `plugin://` 特权协议:路径解析后必须落在该插件目录内(防穿越);`peer://` 仅暴露白名单资产(tokens.css)
- CSP:宿主页只加 `frame-src plugin:`(iframe 方案不动 script-src) - CSP:宿主页 `frame-src plugin:`(iframe)+ `script-src plugin:`(renderer 模块动态 import);.mjs/.js 强制 JS MIME
- 外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——**签名校验前,仅自行放置的插件目录可装载**(M5 后强制) - 外置装载为 **build 级开关**(`EXTERNAL_PLUGINS_ENABLED`:开发版开/用户版关,`ASO_EXTERNAL_PLUGINS=1` 豁免);外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——**签名校验前,仅自行放置的插件目录可装载**(M5 后强制)
- 外置 renderer 模块以渲染层特权运行(无 iframe 沙箱):同样受上述 build 开关门控;组件崩溃不越出 ErrorBoundary
## pi 原生目录(M7,未实现) ## pi 原生目录(M7)
事实:pi resource-loader **默认自动加载** `~/.pi/agent/extensions/*.ts` 与 `<cwd>/.pi/extensions/*.ts`(`noExtensions` 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。M7 计划:设置开关(默认关 → `noExtensions`)+ 管理页可见/单禁 + 参与统一重载。 事实:pi resource-loader **默认自动加载** `~/.pi/agent/extensions/*.ts` 与 `<cwd>/.pi/extensions/* /index.ts`(`noExtensions` 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。
已落地:harness 把同一批目录当作**一等外置插件源**——目录内含 harness `manifest.json` 的子目录获得完整插件待遇(main.mjs 工厂 / entry.renderer React 组件 / entry.page iframe / 面板 tab / 管理页开关)。pi 只自动发现 index.ts/index.js,harness 约定入口为 main.mjs/renderer.mjs,因此**不会被 pi 双重加载**;纯 pi 扩展(index.ts)仍由 pi 原生装载、不参与 harness UI。项目级目录随工作区切换同步(`setProjectExtensionsDir`)。未做:项目目录的 project-trust 门控(与 pi 侧对齐)、设置页按源展示。
## 开发工作流(外置插件,M6 模板落地后) ## 开发工作流(外置插件,M6 模板落地后)
@@ -113,3 +214,8 @@ packages/plugin-template → npm i(workspace 引 @aso/plugin-sdk)
页面:vite build --watch → dist/ # 改即重编 页面:vite build --watch → dist/ # 改即重编
宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src 宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src
``` ```
**重载粒度**:`plugins:reload` 是宿主级动作——重扫全部插件目录 + 重导入 main.mjs +
清内联 UI + 重建运行时(所有插件短暂重激活)。没有单插件重载粒度(工具聚合在同一个
pi 运行时里,无法热替换单个);改了哪个插件都执行同一个重载。排障走
[docs/plugins/09-troubleshooting.md](./plugins/09-troubleshooting.md)。
+85
View File
@@ -0,0 +1,85 @@
# L0 · 第一个插件
> 目标:10 分钟内拥有一个被宿主装载、可在日志里看到自己痕迹的最小插件。不需要构建工具。
## 1. 建目录
放在**全局**插件目录(所有工作区可见;项目级放 `<工作区>/.pi/extensions/`,见 README):
```
~/.pi/agent/extensions/hello/
manifest.json
main.mjs
```
> Windows 下即 `C:\Users\<你>\.pi\agent\extensions\hello\`。
## 2. manifest.json
```json
{
"id": "hello",
"name": "Hello",
"version": "0.1.0",
"minContract": "0.1.0",
"description": "我的第一个插件"
}
```
字段规则:`id` 必须匹配 `^[a-z][a-z0-9-]*$`;`version` 是 semver;`minContract` 是要求的宿主契约版本(当前 `0.2.0`,填 `0.1.0` 即可向后兼容)。全字段见 [../plugins.md](../plugins.md#manifestjson)。
## 3. main.mjs
```js
// 默认导出一个工厂:宿主激活插件时调用,返回的函数在禁用/重载时被调用
export default function definePlugin(api) {
api.log.info('hello activated', { version: api.manifest.version })
// 演示:往 Agent 可见的目录里注册一个极简工具
api.pi.registerTool({
name: 'hello_now',
description: '返回当前时间的问候语',
parameters: {},
async execute() {
api.log.debug('hello_now called')
return { output: `Hello! 现在是 ${new Date().toISOString()}` }
}
})
return () => {
api.log.info('hello disposed')
}
}
```
> `api` 的完整形状(pi / ui / storage / log / manifest)见 [../plugins.md](../plugins.md#主进程插件契约aso-plugin-sdk-主入口)。
## 4. 验证
1. 重启应用(`pnpm dev`;扫描发生在启动与插件重载时)
2. 打开 设置 → Agent 能力 → 插件 → **全局** 作用域,应看到「Hello v0.1.0」,开关为「已启用」
3. 打开日志文件 `userData/logs/main-YYYY-MM-DD.log`,应能搜到:
```
<时间> INFO [plugin-host] external main loaded pluginId=hello version=0.1.0
<时间> INFO [plugin-host] plugin activated pluginId=hello source=external
<时间> INFO [plugin] hello activated pluginId=hello version=0.1.0
```
## 5. 改代码后如何生效
- 改 `main.mjs`:设置 → 插件页任意开关触发重载,或重启应用(宿主以 cache-bust 重新 import)
- 改 `manifest.json`:同上;`id` 变了等于新插件
## 常见装载失败
| 现象(日志里搜) | 原因 |
|---|---|
| `external main load failed` | main.mjs 语法错误 / import 了不存在的模块 |
| `external main invalid export` | 忘了 `export default`,或默认导出不是函数 |
| `需要契约版本 >=` | manifest.minContract 高于当前宿主契约(0.2.0) |
| 插件页全局作用域为空 | 目录放错(注意是 `extensions` 复数);或用户版构建(装载关闭) |
---
**下一篇**:[L1 工具与命令](./02-tools-and-commands.md)——把真正的能力交给 Agent。
+91
View File
@@ -0,0 +1,91 @@
# L1 · 工具与命令
> 前置:[L0](./01-hello-plugin.md)。`api.pi` 就是 pi 的原生 `ExtensionAPI`——pi CLI 扩展能做的这里都能做,语义一致。
## 1. 注册一个带参数的工具
工具是 Agent 的「手」:注册后自动进入系统提示词与工具列表,受审批拦截管理。
```js
export default function definePlugin(api) {
api.pi.registerTool({
name: 'dice_roll',
description: '掷一个 N 面骰子,返回点数',
parameters: {
type: 'object',
properties: {
sides: { type: 'number', description: '面数,默认 6' }
}
},
async execute(_id, params) {
const sides = Number(params.sides) || 6
const value = 1 + Math.floor(Math.random() * sides)
api.log.info('dice rolled', { sides, value })
return { output: String(value), metadata: { sides, value } }
}
})
}
```
要点:
- `description` 会注入系统提示词,写清楚「什么时候该用这个工具」,Agent 的调用准确率取决于它
- `execute` 返回 `{ output: string }`——output 是喂回模型的内容,保持精炼
- 工具名全局唯一,用 `<插件名>_<动作>` 前缀避免撞名
## 2. 注册斜杠命令
命令是用户的「入口」:聊天框输入 `/` 出现,回车触发。
```js
export default function definePlugin(api) {
api.pi.registerCommand({
name: 'dice',
description: '掷骰子',
handler: async (ctx, args) => {
const sides = Number(args) || 6
ctx.notify(`🎲 ${1 + Math.floor(Math.random() * sides)} / ${sides}`)
}
})
}
```
## 3. 订阅事件
在 Agent 生命周期钩子上挂逻辑(发消息前注入、工具调用前后处理等):
```js
export default function definePlugin(api) {
const off = api.pi.on('before_agent_start', (event) => {
api.log.debug('agent starting', { prompt: event.prompt?.slice(0, 50) })
})
// 返回的取消函数放进 dispose,宿主在禁用/重载时调用
return () => off()
}
```
常用事件:`before_agent_start` / `agent_start` / `tool_call` / `tool_result` / `before_provider_request` / `agent_end`。全集见 pi 的 `ExtensionAPI` 类型(`@earendil-works/pi-coding-agent`)。
## 4. 与插件私有存储配合
需要跨回合记住状态时用 `api.storage`(按插件隔离,落盘 `aso-plugin-storage.json`):
```js
let count = api.storage.get('count') ?? 0
api.storage.set('count', ++count)
```
## 5. 验证
1. 重载插件(开关切换或重启)
2. 日志确认 `plugin activated`
3. 聊天框输入 `/dice` 回车 → 收到点数;对 Agent 说「掷个 20 面骰」→ 观察它调用 `dice_roll`
4. 日志中应出现 `dice rolled sides=20 value=…`
## 排障
- 工具没出现在 Agent 里:日志搜 `plugin activated` 确认激活成功;`registerTool` 抛错会记为 `plugin activation failed`
- 命令没出现在 `/` 面板:命令名冲突(重名被去重),换名再试
---
**上一篇** [L0](./01-hello-plugin.md) · **下一篇**:[L2 设置表单](./03-settings.md)——让用户能配置你的插件。
+90
View File
@@ -0,0 +1,90 @@
# L2 · 设置表单
> 前置:[L0](./01-hello-plugin.md)。声明式 schema:插件声明「要什么配置」,宿主负责渲染、持久化、实时回推。不用写任何 UI 代码。
## 1. 声明设置
```js
export default function definePlugin(api) {
api.ui.registerSettings({
items: [
{ type: 'string', key: 'apiKey', label: 'API Token', password: true, placeholder: 'sk-...' },
{ type: 'boolean', key: 'verbose', label: '详细日志', default: false },
{
type: 'select', key: 'endpoint', label: '接入点', default: 'prod',
options: [
{ value: 'prod', label: '生产' },
{ value: 'staging', label: '预发' }
]
},
{ type: 'button', key: 'test', label: '测试连接', variant: 'primary' }
]
})
}
```
保存后(开关切换或重启):
- 设置 → 插件 → **全局/项目** 作用域里,该插件行内出现 ⚙ 按钮(打开配置页)和「一级入口」开关
- **items 为空或不声明 → 两个控件都不出现**(这正是"留空则无一级入口按钮"的规则)
## 2. 一级入口
行内「一级入口」开关打开后,设置弹窗左侧导航「Agent 能力」组末尾出现以插件名命名的入口,点击进入该插件的独立配置页。用户随时可以关掉。
## 3. 读写值
```js
const values = api.ui.getSettingValues() // { apiKey: '...', verbose: false, endpoint: 'prod' }
// schema 里的 default 已合并——没被用户改过的键直接拿默认值
api.ui.onSettingsChange((next) => { // 用户在设置页改值后实时推送
values = next
api.log.info('settings changed', { endpoint: next.endpoint })
})
api.ui.onSettingsAction((key) => { // button 条目点击(key = 按钮项 key)
if (key === 'test') {
const ok = values.apiKey ? '✅' : '❌ 缺少 API Token'
api.ui.setStatusItem('test', { text: `连接测试 ${ok}`, tone: 'accent' })
}
})
```
值持久化在 `userData/aso-plugin-values.json`(按插件隔离),重装插件后仍在。
## 4. 13 种标准控件
| type | 说明 | 关键字段 |
|---|---|---|
| `string` | 单行输入 | `password` 掩码、`placeholder` |
| `text` | 多行文本 | `rows` |
| `number` | 数字 | `min` / `max` / `step` |
| `boolean` | 开关 | — |
| `select` | 下拉单选 | `options[{value,label}]` |
| `multiSelect` | 多选(chip 组) | `options` |
| `slider` | 滑条 | `min` / `max` 必填、`step` |
| `color` | 颜色(#rrggbb) | — |
| `path` | 文件/目录选择 | `mode: 'file' \| 'directory'` |
| `image` | 图片选择 + 缩略图 | — |
| `button` | 动作按钮 | `variant: 'primary' \| 'ghost'` |
| `info` | 静态说明 | — |
| `divider` | 分割线/小节标题 | `label?` |
全部条目支持 `label` / `description` / `default`(button/info/divider 除外)。
## 5. 设计约束(为什么是数据不是代码)
- schema 是**纯数据**:不允许函数/React 元素——宿主跨进程渲染并持久化快照
- 需要动态选项(比如扫描出来的设备列表)?在激活时计算好再 `registerSettings`,每次激活都会重新声明
- 需要复杂交互 UI?那不是设置的职责——走 [L4 React 组件](./05-renderer-components.md)
## 6. 验证
1. 重载插件 → 插件页该行出现 ⚙ 与「一级入口」
2. 打开配置页改一个值 → 日志出现 `settings changed`(`onSettingsChange` 生效)
3. 点按钮 → 状态胶囊出现(动作回调生效)
4. `userData/aso-plugin-values.json` 里能看到写入的键值
---
**上一篇** [L1](./02-tools-and-commands.md) · **下一篇**:[L3 UI 槽位](./04-ui-slots.md)——状态胶囊、面板、视图与 iframe 页。
+121
View File
@@ -0,0 +1,121 @@
# L3 · UI 槽位:胶囊、工具栏、视图、面板、iframe 页
> 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。
## 1. 状态胶囊(悬浮胶囊:最小化药丸 ⇄ 展开面板)
所有插件**共用一个悬浮胶囊**,悬浮于 main 区域右上角(侧面板打开时自动左移让位;ZCode 复刻)。插件照常 `setStatusItem`,宿主记录每次写入时间,两种形态:
- **最小化药丸**:展示「最新的消息」(全部插件中 `updatedAt` 最新的一条:tone 点 + 插件图标 + 文本;多于一条时带总数角标),点击展开面板;
- **展开面板**:面板头就是插件名(第一个区域的右侧是收回药丸的 ⤡);多个插件时各占一个区域、区域头可收起/展开(记忆跨启动)。区域内 `detail` 的**分组是手风琴**——默认收起只显示「分组标题 + 行尾徽标」,点击展开 rows(ZCode 式「计划 ›」);无标题分组直接平铺。面板最大高度封顶(min(480px,58vh)),高度随内容自适应,溢出才滚动;点击面板外部 / Esc 收回药丸。
```js
// ① 纯展示(只出现在药丸/区域行)
api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' })
// ② 点击深链
api.ui.setStatusItem('conn', {
text: '已连接 · 3 任务',
tone: 'accent', // default | accent | warn | danger
icon: '<svg .../>', // 可选,12px 线条风
openView: { view: 'my-plugin' } // 点击跳到插件整页视图
})
// ③ 携详情(分组 = 手风琴:收起显示 标题+trailing 徽标,点击展开 rows)
api.ui.setStatusItem('tasks', {
text: '任务 2/5',
tone: 'accent',
detail: {
title: '任务概览', // 面板中省略(区域头已是插件名)
sections: [
{
title: '进行中', // 分组标题可省略
rows: [
{ label: '构建索引', detail: '已完成 60%', trailing: '2/5', tone: 'accent' },
{ label: '清理临时文件' }
]
},
{
title: '操作',
rows: [{ label: '打开任务面板', openView: { view: 'my-plugin' } }] // 行点击深链
}
]
}
})
api.ui.removeStatusItem('tasks')
```
弹层由宿主统一渲染(分组标题 / 行 / 右侧徽标 / 深链),样式与宿主一致——插件只提交
数据;点击弹层外部或 Esc 关闭;dispose 时宿主自动清理本插件全部条目。
## 2. 工具栏按钮(输入区工具行)
```js
api.ui.registerToolbarButton({
id: 'open',
title: '打开我的面板',
icon: '<svg .../>', // 16-20px 线条风,currentColor
openView: { view: 'my-plugin' }
})
```
## 3. 整页视图 + 侧栏入口
manifest 声明 `icon` / `order` 后,插件自动出现在侧栏(有 UI 能力时);任何槽位用 `openView` 深链:
```js
api.ui.openView('my-plugin', { section: 'detail' }) // params 透传给插件 UI
api.ui.openView('chat') // 回到聊天
```
## 4. 面板 tab(右侧 SidePanel)
manifest 加 `"panel": true`,插件在右侧面板获得一个 tab。
## 5. iframe 插件页(entry.page)
需要**完全自定义的 Web UI** 且不想构建 React 时用。物理隔离(sandbox iframe),经桥通信:
```
<插件目录>/index.html + 页面 JS
manifest: "entry": { "page": "index.html" }
```
页面侧用 SDK 桥(`@aso/plugin-sdk/client`):
```html
<script type="module">
import { getHarnessClient, useHarnessTheme, useDeepLinkParams } from './sdk-client.mjs'
const harness = getHarnessClient('my-plugin')
harness.openView('chat') // 深链
harness.setStatus('state', { text: '就绪' })
harness.invoke('getState') // 白名单:getState / send / getSettings
</script>
```
**样式统一(强制)**:插件页 `<head>` 里必须引宿主 token 表,并用自己的 Tailwind 按同样 token 名编译——
```html
<link rel="stylesheet" href="peer://assets/tokens.css" />
```
主题(深浅色/accent)由宿主推送(`useHarnessTheme`),禁止自行探测。详细规则见 [DESIGN.md](./DESIGN.md#iframe-插件页)。
## 6. 安全边界速记
| 形态 | 代码位置 | 能力 |
|---|---|---|
| main.mjs | 主进程 | 全权(与 pi 扩展同信任级) |
| renderer.mjs(L4) | 渲染层 | 宿主 React + window.api 受控面 |
| iframe 页 | 沙箱 iframe | 仅白名单桥(getState/send/getSettings/setStatus/openView) |
## 7. 验证
1. 状态胶囊:`setStatusItem` 后 TitleBar 立即出现,`removeStatusItem` 消失
2. 面板 tab:`panel: true` + 任一 UI 形态 → 右侧面板出现 tab
3. iframe 页:改页面文件 → 插件页开关触发重载 → iframe 换 src 整页刷新
4. 全部 UI 状态在日志可查(`plugin activated` 之后你在 main.mjs 里的 `api.log` 输出)
---
**上一篇** [L2](./03-settings.md) · **下一篇**:[L4 React 组件](./05-renderer-components.md)——宿主内真 React 组件。
+98
View File
@@ -0,0 +1,98 @@
# L4 · 宿主内 React 组件(entry.renderer)
> 前置:[L3](./04-ui-slots.md)。iframe 页是隔离的「贴进去的网页」;`entry.renderer` 则是**真正的宿主内 React 组件**——与内置插件同槽位、同观感、同主题。这是外置插件的最高 UI 形态。
## 1. 声明
```json
{ "id": "my-plugin", "panel": true, "entry": { "renderer": "renderer.mjs" } }
```
> 文件名**不要叫 index.ts/index.js**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载。
## 2. renderer.mjs 契约
宿主动态 import 后调用默认导出 `mount(ctx)`:
```js
export default function mount(ctx) {
// ctx.react 是宿主的 React 单实例——必须用它创建组件(自带 React 副本会让 hooks 全部失效)
const R = ctx.react
function Panel() {
const [n, setN] = R.useState(0)
return R.createElement('div', { className: 'p-3 space-y-2' },
R.createElement('div', { className: 'text-[12.5px] text-ink' }, `点击 ${n} 次`),
R.createElement('button', {
className: 'rounded-control border border-line-strong px-2.5 py-1 text-[11.5px] text-ink hover:bg-hover',
onClick: () => setN(n + 1)
}, '+1')
)
}
ctx.registerPanel(Panel) // 右侧面板 tab 内容(需 manifest.panel: true)
ctx.registerView(Panel) // 整页视图(侧栏入口 → 主内容区);两个都注册也可以
}
```
## 3. JSX 工程构建配方(推荐)
手写 `createElement` 只适合玩具;真实插件用 JSX + esbuild,构建时把 `react` 别名到**宿主全局实例**:
```sh
esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
--jsx=automatic \
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js \
--alias:@aso/plugin-sdk=<sdk源码路径>/index.ts --alias:@aso/plugin-sdk/ui=<sdk源码路径>/ui.tsx
```
> 最后两条 alias 只在用到 `@aso/plugin-sdk`(withTimeout 等)或 `./ui` 组件库时需要;
> sdk 以源码形态发布(exports 指向 .ts/.tsx),esbuild 无法按包名解析,指路径即可。
```js
// shims/react.js —— 从宿主全局取单例
const R = window.__ASO_PLUGIN_HOST__.react
export default R
export const { useState, useEffect, useRef, useMemo, useCallback, createContext,
useContext, Fragment, memo, forwardRef, createElement } = R
```
```js
// shims/jsx-runtime.js
export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime
```
`packages/plugin-template/` 已内置这套配置,复制即用。
## 4. 硬性约束
| 约束 | 原因 |
|---|---|
| React 必须来自 `ctx.react` / 宿主全局 | 双 React 实例 → hooks 全部报错 |
| 模块**自包含**(单文件 bundle) | 宿主以 Blob URL 装载,相对导入没有基路径 |
| 不 import 裸包名(react 等) | 同上;一切依赖打进 bundle(react 走别名除外) |
| 单文件 ≤ 8MB | 超限拒绝装载 |
| 仅开发版装载 | 用户版第三方装载关闭(`ASO_EXTERNAL_PLUGINS=1` 豁免) |
装载失败/白屏/hooks 报错?直接查 [常见错误速查](./09-troubleshooting.md) §3。
## 5. 样式
组件直接跑在宿主树里——**宿主的 Tailwind 类与 token 全部可用**。标准模式已代码化:优先 `import { Button, SettingRow, LoadingState, ErrorState, EmptyState, Switch } from '@aso/plugin-sdk/ui'`(组件目录与三态铁律见 [L6](./07-ui-components.md)),不要手写;遵守 [DESIGN.md](./DESIGN.md) 的类名词汇,禁止硬编码色值/阴影。
## 6. 生命周期
- 装载:插件列表变化(启停/重载/启动)即重装;`mount` 同步执行注册
- 卸载:插件禁用/移除 → 注册的组件自动从宿主卸载
- 性能护栏:读源与 import 各限时 8s——超时/失败时槽位呈现宿主统一「装载失败」ErrorState(带重试),见 [L7](./08-performance.md)
- 崩溃隔离:渲染错误被 `PluginErrorBoundary` 捕获,不影响宿主
## 7. 验证
1. 重载插件 → 插件页该行出现 ⚙(若已声明设置)/侧栏入口/面板 tab
2. 控制台无 `[plugins] … renderer 装载失败`
3. 点击组件内按钮,state 正常更新(hooks 生效 = 单实例约束满足)
---
**上一篇** [L3](./04-ui-slots.md) · **下一篇**:[L6 公共组件库](./07-ui-components.md)——别手写样式,宿主把标准模式做成了组件。
+84
View File
@@ -0,0 +1,84 @@
# L5 · 自诊断:日志规范与 Agent 自迭代闭环
> 本项目的愿景是「Agent 写插件、Agent 排障、Agent 迭代」。达成的前提是**一切可观测**:宿主日志、插件日志、渲染层错误都落盘且结构化。这一篇讲日志从哪来、到哪去、Agent 怎么用它排障。
## 1. 日志在哪
```
userData/logs/main-YYYY-MM-DD.log ← 唯一日志文件,按天滚动,保留 14 天
```
Windows:`C:\Users\<你>\AppData\Roaming\@aso\desktop\logs\`
(Agent 侧:pi 的 read/grep 工具可直接读这个路径——排障第一步永远是读它。)
级别:dev 默认 `debug`,打包版默认 `info`;环境变量 `ASO_LOG_LEVEL=debug|info|warn|error` 覆盖。
## 2. 行格式
```
<ISO时间> <LEVEL> [<scope>] <message> k=v k=v
2026-09-10T10:21:33.120Z INFO [plugin-host] plugin activated pluginId=demo-ui source=external
2026-09-10T10:21:33.451Z INFO [plugin] settings changed pluginId=demo-ui endpoint=staging
2026-09-10T10:21:40.002Z ERROR [ipc] ipc failed channel=plugins:setEnabled ms=1203 err=Error: runtime rebuild failed
```
scope 一览:`main` / `plugin-host` / `plugin`(所有插件日志,带 `pluginId` 字段)/ `agent-service` / `browser` / `ipc` / `renderer` / `logger`。
## 3. 插件怎么写日志:`api.log.*`
```js
export default function definePlugin(api) {
const log = api.log // 自动带 pluginId 字段,进同一文件
api.log.info('activation payload', { tools: 3, mode: 'standard' })
try {
risky()
} catch (err) {
api.log.error('risky step failed', { step: 'connect', err }) // err 会被序列化成 stack
}
}
```
**埋点规范**(Agent 排障依赖这些惯例):
- 激活时:`info` 一条「我装了什么」(能力清单/配置摘要)
- 关键决策与外部调用:`info` / `debug` + 结果字段
- 一切 catch:`error` + `err` 字段——**禁止吞错不记**
- message 用英文短句、kv 用结构化字段(便于 grep `pluginId=xxx`)
## 4. 宿主替你记录的(不用自己写)
| 事件 | scope | 级别 |
|---|---|---|
| 插件扫描/装载/激活/禁用/重载全链路 | plugin-host | info |
| main.mjs 加载失败 / 激活失败 | plugin-host | error |
| 设置值变更 / 一级入口切换 / 按钮动作 | plugin-host | info/debug |
| 全部 73 个 IPC 通道调用(成功 debug 含耗时,失败 error) | ipc | debug/error |
| Agent 回合开始/结束/结算、运行时重建 | agent-service | info/error |
| 渲染层 console(含插件 renderer 模块与 iframe 页)、白屏、崩溃 | renderer | debug/error |
| 进程级 uncaughtException / unhandledRejection | logger | error |
## 5. Agent 排障闭环(自迭代的核心流程)
```
发现问题(用户反馈 / 日志 error)
↓ 读 userData/logs/main-<今天>.log
↓ grep pluginId=<自己的id> 与 ERROR
↓ 对照本文第 4 节判断:装载失败?激活抛错?IPC 超时?渲染崩溃?
↓ 改插件代码(L0-L4 的对应章节)
↓ 触发重载(设置页开关 / 重启)→ 再读日志验证新一次 plugin activated
↓ 循环直到日志干净
```
写插件时请遵守:**每个可能失败的操作都有日志、每条 error 都能定位到步骤**。这是 Agent 能替你维护插件的最低要求。
## 6. 快速自查清单
- [ ] 装载了吗:`external main loaded pluginId=<id>`
- [ ] 激活了吗:`plugin activated pluginId=<id>`
- [ ] 有报错吗:`ERROR` + `pluginId=<id>`
- [ ] IPC 通吗:`ipc failed channel=…` 是宿主问题还是插件问题
- [ ] 渲染崩了吗:`renderer console` 中 `[window.onerror]` / `[unhandledrejection]`
---
**上一篇** [L4](./05-renderer-components.md) · 回到 [索引](./README.md) · 契约全集 [../plugins.md](../plugins.md) · 视觉规范 [DESIGN.md](./DESIGN.md)
+83
View File
@@ -0,0 +1,83 @@
# L6 · 公共 UI 组件库(@aso/plugin-sdk/ui)
> 前置:[L4 React 组件](./05-renderer-components.md)。约 15 分钟。
> 目标:不写一行样式代码,做出与宿主完全一致的界面;等待/失败/空态不再自造。
## 为什么必须用它
[DESIGN.md](./DESIGN.md) 定了「长什么样」,组件库把它变成「直接拿来的代码」:
- **token 同源**:组件内部只用宿主已核验的类词汇(`text-ink-3`、`rounded-control`…)
与 `var(--token)` 内联变量,深浅色自动跟随宿主主题
- **三态统一**:等待 `LoadingState` / 失败 `ErrorState`(可重试)/ 空 `EmptyState`,
任何插件的加载体验与宿主一致
- **零依赖**:`./ui` 是 sdk 源码子路径,随 esbuild 一起打进 renderer.mjs,
不引任何第三方 UI 库(shadcn/ui 的思路——无运行时、复制即用——由宿主承担)
## 导入与构建
```tsx
import { Button, LoadingState, ErrorState, EmptyState, SettingRow, Switch } from '@aso/plugin-sdk/ui'
```
构建命令与 L4 完全相同——`ui` 随 sdk 源码一起被打包,react 别名照旧:
```bash
esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
--jsx=automatic --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js
```
## 组件目录
| 组件 | 用途 | 要点 |
|---|---|---|
| `Spinner` | 行内旋转指示 | `size` 默认 14px |
| `LoadingState` | 区块等待态 | `label` 可换文案,默认「加载中…」 |
| `ErrorState` | 区块失败态 | `title`/`description`(错误消息)、`onRetry` 提供即显示重试按钮 |
| `EmptyState` | 区块空态 | `title` + `description` + `action`(如「去设置」按钮) |
| `Button` | 按钮 | `variant: 'primary' \| 'ghost' \| 'danger'`;`loading` 置灰并显示 Spinner,防重复提交 |
| `Input` / `Textarea` | 输入 | 与宿主设置页同款外观 |
| `Select` | 下拉 | `options: { value, label }[]`,原生 select |
| `Switch` | 开关 | 受控 `checked`/`onCheckedChange`,或非受控 `defaultChecked` |
| `Card` | 卡片容器 | `rounded-card` + line 边框 |
| `SettingRow` | 设置行 | label 左 / 控件右(DESIGN §4 标准模式) |
| `Badge` | 徽标 | `tone: 'neutral' \| 'accent' \| 'danger'`,版本/来源/状态元信息 |
全部组件接受 `className` 追加布局类(flex/间距/尺寸),**不接受**颜色/字号覆写——
那两条路(hex、新字号)被 [L7 检验器](./08-performance.md)拦截。
## 三态铁律
任何异步区块的状态机:**loading → data | error | empty**,四选一,不允许第五种
(白屏、裸文本「加载中」、无反馈的失败)。模板(plugin-template)是完整示范:
```tsx
{error !== null ? (
<ErrorState title="数据加载失败" description={error} onRetry={() => setTick((t) => t + 1)} />
) : items === null ? (
<LoadingState label="正在拉取数据…" />
) : items.length === 0 ? (
<EmptyState title="暂无数据" description="检查数据源后点击重新加载" />
) : (
items.map((it) => <SettingRow key={it} label={it} />)
)}
```
配套铁律:异步调用必须 `withTimeout`/`withRetry`(见 L7),否则 loading 态可能永远挂着。
## 宿主兜底
renderer 模块的读源与 import 宿主各限时 8 秒:超时或失败时,插件槽位自动呈现宿主侧
「插件界面装载失败」ErrorState(带重试按钮);组件渲染期崩溃由 PluginErrorBoundary
兜底。也就是说**装载失败不需要插件自己处理**,插件只需管好自己组件内部的异步。
## 边界
- 仅 renderer 组件槽位可用(渲染在宿主 DOM,复用宿主样式表);iframe 插件页是物理
隔离文档,请按 [DESIGN.md](./DESIGN.md) §4 的 HTML 模式自行实现
- 需要新形态时:先用现有组件拼;确实不够,在 DESIGN.md 约束内组合 token,
并把新形态沉淀回团队公共代码,而不是留在单个插件里
---
**上一篇** [L5 自诊断](./06-self-diagnosis.md) · **下一篇**:[L7 性能与检验](./08-performance.md)——超时、重试与机器关卡。
+109
View File
@@ -0,0 +1,109 @@
# L7 · 性能与检验(plugin:check)
> 前置:L0–L6。约 20 分钟。
> 目标:插件百花齐放,但**绝不卡宿主、绝不风格跑偏**;发布前有机器关卡把关。
## 性能铁律
### 1. 主进程不阻塞(检验器:error)
`main.mjs` 运行在宿主主进程。任何同步 IO——`fs` 的 `*Sync`、`execSync`、
`spawnSync`——都会卡住**整个应用**的事件循环(聊天流、其他插件全部冻结)。
一律用 `node:fs/promises` 与异步子进程:
```js
// ❌ const text = fs.readFileSync(path, 'utf8')
// ✅
import { readFile } from 'node:fs/promises'
const text = await readFile(path, 'utf8')
```
**存储也不例外**:`api.storage.*`(SQLite)在宿主的共享 worker 线程里执行,
主进程只做异步 RPC——大查询不会卡聊天流(见 [L9](./11-storage.md))。
自己开的文件句柄同理:全 async。
### 2. 激活限时 10 秒(宿主强制)
插件工厂若返回 Promise,宿主限时 10s;超时或 reject 即把插件标记为失败
(插件管理页可见红字),绝不无限等待拖垮 runtime 重建。因此:
- 激活路径只做「注册」级别的工作,重活(预取数据、预热连接)放到工具首次调用
- 自己 `catch` 并 `api.log.error`——被宿主兜住意味着插件没跑起来
### 3. renderer 装载限时 8 秒(宿主强制)
读源与 import 各限时 8s,超时/失败由宿主呈现统一失败态(重试按钮);
组件渲染期崩溃由 PluginErrorBoundary 兜底。插件只需管好组件内部的异步。
### 4. 自己的异步自己兜(sdk 提供 `withTimeout` / `withRetry`)
先于宿主超时并自行恢复,而不是等宿主判死:
```js
import { withTimeout, withRetry, TimeoutError } from '@aso/plugin-sdk'
// 网络请求:3s 超时,失败退避重试 2 次(300ms → 600ms)
const data = await withRetry(() => callApi(), { retries: 2, delayMs: 300, timeoutMs: 3000 })
// 单次限时
const cfg = await withTimeout(loadConfig(), 2000, '加载配置')
try {
await risky()
} catch (err) {
if (err instanceof TimeoutError) { /* 降级路径 */ }
throw err
}
```
注意:`withTimeout` 只是竞速,底层操作不会被取消——有 `AbortController` 的
调用方请把 signal 接到底层,再用它兜底计时。
### 5. 渲染不卡顿
长列表分页/虚拟化,不要一次渲染数万节点;重计算放进工具(Agent 侧)或
`setTimeout(fn, 0)` 分帧,不占渲染主线程。
## 三态呈现(检验器:warn)
任何异步 UI 必须是四态之一:`LoadingState` / `ErrorState` / `EmptyState` / 数据。
`alert`/`confirm`/`prompt` 是阻塞式同步对话框,禁止(用 `Button` + 状态组件替代)。
详见 [L6](./07-ui-components.md)。
## 样式一致性(检验器:error)
- 只用公共组件库 + [DESIGN.md](./DESIGN.md) token;硬编码 hex 色值是 error
(`#fff`/`#ffffff` 白字除外)
- renderer.mjs 必须经宿主 shim 使用单实例 React(`__ASO_PLUGIN_HOST__`)——
自带 React 副本 = hooks 全部失效
## 日志(检验器:error)
`main.mjs` 禁 `console.*`,统一 `api.log.*`——Agent 自诊断闭环见 [L5](./06-self-diagnosis.md)。
## 检验器:`pnpm plugin:check <插件目录>`
发布前必跑;Agent 自迭代时,**通过检验器是完成定义(DoD)的一部分**。
| 级别 | 检查项 |
|---|---|
| error | manifest 缺失/不可解析;id/name/version(semver) 缺失;minContract 高于宿主契约;entry 指向的文件不存在;renderer.mjs > 8MB / main.mjs > 2MB;main.mjs 同步 IO;main.mjs `console.*`;renderer.mjs 未引用 `__ASO_PLUGIN_HOST__`;任何源码硬编码 hex 色值 |
| warn | 缺 minContract;renderer.mjs > 300KB(疑似打包自带 React);注册 view/panel 但未用三态组件;`alert/confirm/prompt`;entry.renderer 产物缺失但有 src/renderer.tsx(未构建) |
宿主契约版本从 `plugin-host.ts` 源码提取(单一事实源),检验器与宿主永不会各执一词。
## 一张自查表
- [ ] `pnpm plugin:check <目录>` 0 error
- [ ] main.mjs 无同步 IO,激活 10s 内完成
- [ ] 一切异步有超时与重试,catch 后 `api.log` + `ErrorState`
## 一张自查表
- [ ] `pnpm plugin:check <目录>` 0 error
- [ ] main.mjs 无同步 IO,激活 10s 内完成
- [ ] 一切异步有超时与重试,catch 后 `api.log` + `ErrorState`
- [ ] 长列表不整批渲染
- [ ] 深浅色各看一眼(token 正确时自动成立)
---
**上一篇** [L6 公共组件库](./07-ui-components.md) · 出问题了?[常见错误速查](./09-troubleshooting.md) · 返回 [教程索引](./README.md)
+99
View File
@@ -0,0 +1,99 @@
# 附 · 常见错误速查(排障表)
> 面向「正在排障的 Agent/开发者」。**症状 → 原因 → 修复**三段式,按报错文本 grep 即可定位。
> 日志第一入口:`userData/logs/main-YYYY-MM-DD.log`(scope=plugin 的行带 pluginId,见 [L5](./06-self-diagnosis.md))。
> 改完任何文件后跑 `pnpm plugin:check <插件目录>`,再点插件页的「重载」。
## 1. 插件没出现在列表里
| 症状 | 原因 | 修复 |
|---|---|---|
| 设置 → 插件 → 对应作用域看不到插件 | 目录位置不对 | 只认三处:`userData/plugins/<id>/`、`<工作区>/.pi/extensions/<id>/`、`~/.pi/agent/extensions/<id>/`(工作区切换后项目级目录才同步) |
| 用户版构建下第三方区显示「装载已关闭」 | `EXTERNAL_PLUGINS_ENABLED` 仅开发版开启 | 用 dev 版,或启动加 `ASO_EXTERNAL_PLUGINS=1` |
| 列表有但灰着 | 插件被禁用(`aso-plugin-settings.json` disabled 列表) | 打开「启动」开关 |
| `plugin:check` 报 manifest 错误 | 缺字段 / version 非 semver / minContract 高于宿主契约 | 对照 [docs/plugins.md](../plugins.md) manifest 表;宿主契约版本以 `plugin-host.ts` 的 `HARNESS_CONTRACT_VERSION` 为准 |
| 目录里有代码但永不装载 | 目录名/入口是 `index.ts`/`index.js` | **不要用这两个名字**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载;用 `main.mjs` |
## 2. main.mjs 激活失败
| 症状 | 原因 | 修复 |
|---|---|---|
| 插件行红字「激活失败: …」,日志有 `plugin activation failed` | 工厂抛错 | 看 err 信息修代码;激活路径只做「注册」级工作 |
| 红字「激活失败: … 超时(10000ms)」 | 异步工厂挂死超过 10s | 网络等慢操作移到工具首次调用;必须等待时用 `withTimeout` 先于宿主超时 |
| 应用整体卡顿、聊天停摆 | main.mjs 里同步 IO(`*Sync`/`execSync`)阻塞主进程事件循环 | 一律换 `node:fs/promises` 与异步子进程;检验器会标 error |
| 日志里没有插件的任何输出 | 用了 `console.*`(不落盘) | 改 `api.log.*`(scope=plugin,带 pluginId) |
| 其他插件的胶囊/按钮全消失了 | 该插件激活抛错触发 `clearPluginUi` | 修复激活错误;胶囊/按钮在插件 dispose/出错时被宿主清理是预期行为 |
## 3. renderer 装载失败(React 组件)
| 症状 | 原因 | 修复 |
|---|---|---|
| 插件页/面板显示「插件界面装载失败」+ 重试按钮 | 读源或 import 失败(详见下方各行) | 按 title/description 里的 message 对照下几行 |
| message 含 `超时(8000ms)` | 读源或 import 超过 8s | 瘦身 bundle;检查渲染层不 import 巨型依赖 |
| `Minified React error #310`/`Invalid hook call`/hooks 全部报错 | 插件打包了自带 React 副本 | esbuild 加 `--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js`(配方见 [L4](./05-renderer-components.md));产物必须含 `__ASO_PLUGIN_HOST__` |
| `Failed to fetch dynamically imported module` / 相对导入报错 | bundle 不自包含(相对导入无基路径,宿主用 Blob URL 装载) | `--bundle` 单文件产物;一切依赖打进包内(react 走别名除外) |
| 产物没被装载且无报错 | 超过 8MB 上限 | 瘦身;检查是否误打包 node_modules |
| 改了源码但界面没变 | 没重载 | 点插件行「重载」(或重开关插件);renderer 只在列表变化/重载时重装 |
## 4. Agent 能力(工具/命令/事件)不生效
| 症状 | 原因 | 修复 |
|---|---|---|
| 工具/命令没出现 | 改完代码没重载 | `plugins:reload` 是**宿主级**:重扫全部目录 + 重建运行时,所有插件会短暂重激活——改了哪个插件都跑同一个「重载」 |
| 工具在列表里但 Agent 不调用 | description 不清晰 / 参数 schema 不合法 | 按 pi 工具规范写 description 与 parameters |
| 重载后工具重复/残留 | 插件在 dispose 里没清资源 | 返回 dispose 函数清理(宿主会自动清内联 UI,但 pi 注册的资源要自己管) |
## 5. 设置与导航
| 症状 | 原因 | 修复 |
|---|---|---|
| 插件行没有「入口」行 | 未声明 settings(items 为空 = 无配置页)或插件未启动 | `api.ui.registerSettings` 声明至少一项;启动插件 |
| 配置页改了值插件没收到 | 没挂 `onSettingsChange` | 激活时订阅;值由宿主持久化并实时回推 |
| 设置页按钮点了没反应 | 没挂 `onSettingsAction` | 按 key 订阅动作回调 |
| 左侧导航「Agent 能力」没有插件入口 | `settingsNav` 未开启 | 插件行 → 入口开关打开 |
## 6. 依赖与互调(契约 0.3.0)
| 症状 | 原因 | 修复 |
|---|---|---|
| 行红字「缺少依赖插件 x(^1.0.0),请安装后重载」 | 必需依赖未安装 | 把依赖插件放入任一插件目录,点「重载」;宿主不自动安装 |
| 行红字「依赖插件 x 已停用,请先启用」 | 必需依赖被禁用 | 到该依赖插件行打开「启动」;启用后本插件自动恢复 |
| 行红字「依赖插件 x 需要 ^2.0,当前 v1.4.0」 | 版本不满足范围 | 升级依赖插件,或放宽自己 manifest 的范围 |
| 行红字「循环依赖: a → b → a」 | 必需依赖成环 | 解环(改为 optionalDependencies 或重构边界) |
| `requirePlugin` 抛「未声明对 x 的依赖」 | 未在 dependencies/optionalDependencies 声明 | 依赖声明即访问授权——manifest 里加上,并满足其 `minContract` |
| `requirePlugin` 返回 undefined(可选依赖) | 可选依赖缺失/停用/未就绪 | 预期行为;走降级路径 |
| 改了依赖方代码,依赖方行为没变 | 没重载 | 重载是宿主级动作,点同一个「重载」即可 |
## 7. 存储(SQLite)
| 症状 | 原因 | 修复 |
|---|---|---|
| `plugin:check` 报「使用了 api.storage.db 但未调用 declareSchema」 | 用了 SQLite 却没声明机器可读 schema | 激活期调用 `api.storage.declareSchema`,表/字段 description 写清楚(Agent 内省与设置页展示同源) |
| `api.storage.*` 报「存储操作超时(…5000ms)」 | worker 挂死或查询过重 | 检查 SQL 是否全表扫描/锁表;重启应用可恢复 worker |
| 报「database is locked」 | 跨进程/工具直接打开了同一个 data.db | 用宿主 API 访问,不要同时用外部 sqlite 工具长连接 |
| KV 读回来的值不对 | value 不可 JSON 序列化(Date/Map) | 存前转 JSON 兼容类型;大二进制放文件、路径存 KV |
| 想彻底重来 | — | 设置 → 插件 → 二级页「存储区」→ 清除数据(二次确认,整目录删除) |
| 表结构要改 | — | `PRAGMA user_version` 自管迁移(见 [L9](./11-storage.md) §4) |
## 8. 样式不对
| 症状 | 原因 | 修复 |
|---|---|---|
| 界面颜色和宿主不一致 / 深浅色切不过来 | 硬编码 hex/rgb | 只用 token 类词汇(DESIGN.md §2);`plugin:check` 会标 error |
| iframe 插件页没样式 | 未引宿主 token 表 | `<head>` 引 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind |
| 暗色模式下页面发白 | 用了 `prefers-color-scheme` 自测 | 主题由宿主推送(`useHarnessTheme`),删掉自测 |
| 图标是拼图(fallback) | manifest.icon 不在 lucide 白名单 | 改成白名单内的 lucide 名(DESIGN.md §3;`plugin:check` 会 warn) |
| 按钮不是胶囊形 / 圆角不对 | 手写 rounded-control 当按钮 | renderer 用组件库 `Button`(pill 体系);圆角体系 chip6/control8/card10/window14 |
## 9. 检验器对照
`pnpm plugin:check <目录>` 输出的每条 error/warn 都带修复指引;完整规则表见 [L7](./08-performance.md)。速记:
- error = 装不了或必然出问题(同步 IO、console.*、React 副本、hex、超尺寸、契约非法、依赖声明非法、用 db 未声明 schema、requirePlugin 未声明依赖)
- warn = 应当修(缺 minContract、疑似 React 副本、三态缺失、阻塞对话框、icon 不在白名单、产物未构建、声明依赖但 minContract 过低)
## 10. 还不行?
1. `pnpm plugin:check <目录>` → 0 error
2. 重载插件 → 看 `userData/logs/main-*.log` 里 `plugin activated pluginId=<id>` 之后你自己的 `api.log` 输出
3. 对照 [L5 自诊断](./06-self-diagnosis.md) 的闭环流程补日志埋点,再重试
+97
View File
@@ -0,0 +1,97 @@
# L8 插件依赖与服务互调
> 插件不是孤岛:A 依赖 B/C 时,声明、激活顺序、可用性判定、调用规范由宿主统一保证。
> 契约 0.3.0 起;全部机制在 `dependencies` / `optionalDependencies` + `api.service` / `api.requirePlugin` 四个名字里。
## 1. 声明依赖(manifest.json)
```json
{
"id": "wiki-assistant",
"version": "1.2.0",
"minContract": "0.3.0",
"dependencies": {
"fetch-tools": "^1.0.0",
"browser": "*"
},
"optionalDependencies": {
"embed-store": "~2.1.0"
}
}
```
- 值是 **semver 范围**,支持五种写法:`*`(任意)、`^1.2.0`(同主版本)、`~1.2.0`(同次版本)、`>=1.2.0`(下限)、`1.2.0`(精确)。
- `dependencies` 是**必需依赖**:缺失 / 已停用 / 版本不符 / 自身装载失败 → 本插件激活失败。
- `optionalDependencies` 是**可选依赖**:不就绪只降级,`api.requirePlugin(id)` 返回 `undefined`。
- **依赖声明即访问授权**:`requirePlugin` 只对声明过的 id 放行,未声明直接抛错。
## 2. 运行时判定(软绑定,无强行绑定)
宿主在每次激活轮(启停/重载/重建运行时)做三步:
1. **静态预检**:逐项检查必需依赖——
| 状态 | 判定 | 插件行展示的错误 |
|---|---|---|
| missing | resolved 表里没有该 id | `缺少依赖插件 fetch-tools(^1.0.0),请安装后重载` |
| disabled | 在禁用列表里 | `依赖插件 browser 已停用,请先启用` |
| version | 不满足范围 | `依赖插件 fetch-tools 需要 ^1.0.0,当前 v0.9.0` |
| error | 依赖自身装载失败 | `依赖插件 fetch-tools 装载失败,无法激活` |
2. **环检测**:必需依赖图存在环 → 整环不激活,每个成员报 `循环依赖: a → b → a`。
3. **拓扑波次激活**:依赖先激活、依赖者后激活,无依赖关系的插件**同波并行**(异步工厂限时 10s,挂死不拖垮后续波次)。依赖激活失败时,依赖方报 `依赖插件 x 未能激活`。
行为语义(有意为之):
- A 声明依赖 B:**禁用 B → A 激活失败**(错误栏写明原因);**重新启用 B → A 自动恢复**(重建运行时天然重放)。
- **禁用 A 不影响 B**——依赖不级联、不偷改用户的选择。
- **不自动安装**:缺什么报什么,装不装由用户决定(错误文案就是行动指引)。
## 3. 服务互调(api.service / api.requirePlugin)
所有插件的 main 工厂运行在同一 pi 上下文,互调是**同进程直调**(无 IPC、无序列化),宿主只提供注册表与门控:
```ts
// fetch-tools 的 main.mjs —— 暴露服务
export default definePlugin((api) => {
api.service('fetch', async (url: string, init?: RequestInit) => {
const res = await fetch(url, init)
return res.text()
})
})
// wiki-assistant 的 main.mjs —— 消费服务
export default definePlugin(async (api) => {
const fetchTools = api.requirePlugin('fetch-tools') as
| { fetch: (url: string, init?: RequestInit) => Promise<string> }
| undefined
if (!fetchTools) {
// 可选依赖未就绪:降级路径(必需依赖不会走到这里——激活前置检已拦)
}
api.pi.registerTool({
name: 'wiki_lookup',
// ...
execute: async (args) => {
// 服务方法约定 async;调用方自行限时(与宿主性能护栏同构)
const body = await withTimeout(fetchTools!.fetch(args.url), 8_000, 'wiki_lookup')
// ...
}
})
})
```
规则:
- `api.service(name, impl)`:按名键控;重载/停用时随注册表清空,重激活即重新注册。
- `api.requirePlugin(id)`:返回 `{ serviceName: impl }`;未声明 → 抛错;必需依赖未就绪 → 抛错;可选未就绪 → `undefined`。
- **服务方法约定全 async**,调用方用 `withTimeout` 限时、必要时 `withRetry`(见 L7)——被调插件慢不能变成调用方卡死。
- 依赖可以指向内置插件(如 `browser`),内置与外置同一注册表。
## 4. UI 展示
- 管理页插件行名称旁出现**链接图标**,悬停 Tooltip 列出每个依赖:id / 范围 / 必需或可选 / 实时状态(可用·绿 / 未安装·橙 / 已停用·橙 / 版本不符·橙 / 装载失败·红)。
- 依赖问题的详细错误在该插件行红字与二级页展示;「去启用」到对应插件的行上操作。
## 5. 检验器
`pnpm plugin:check` 会校验:依赖 id 合法、范围语法、不能依赖自身、不能同时出现在必需与可选、声明了依赖但 `minContract < 0.3.0`(旧宿主会静默忽略声明)、调用了 `requirePlugin` 却未声明依赖。
+88
View File
@@ -0,0 +1,88 @@
# L9 插件存储(SQLite)
> 每个插件一块**私有**持久化空间:一个 SQLite 库 + 一个自由文件目录。
> KV 简单状态、SQL 结构化数据、文件随便放;schema 必须机器可读声明——它是 Agent 自我迭代与 UI 展示的同一份事实源。
## 1. 空间布局
```
userData/plugin-data/<plugin-id>/
data.db ← SQLite 库(WAL 模式):kv 表(宿主管理)+ 插件自建表
data.db-wal ← WAL 伴生文件
<其余自由文件> ← api.storage.dir() 指向这里,插件自行读写
```
- **每插件一库**(不是共享大库):导出/清除/损坏恢复都以插件为单位,跨插件天然隔离。
- 卸载/移除插件**不删数据**(重装即恢复);删除只在设置页点「清除数据」(二次确认)时发生。
## 2. API(全部 async——底层在宿主共享 worker 线程执行,永不阻塞主进程)
```ts
export default definePlugin(async (api) => {
// ① 声明机器可读 schema(使用 db 前必须声明;plugin:check 强制)
api.storage.declareSchema({
kv: { description: '会话级偏好:ui.<key> = 用户界面偏好' },
tables: [
{
name: 'notes',
description: '用户笔记',
columns: [
{ name: 'id', type: 'TEXT', description: '主键,ulid' },
{ name: 'title', type: 'TEXT', description: '标题,非空' },
{ name: 'created_at', type: 'INTEGER', description: '创建时间,Unix 毫秒' }
]
}
]
})
// ② KV:宿主管理的 kv 表(value 走 JSON 编码,适合设置类小状态)
const last = await api.storage.kv.get<string>('ui.lastQuery')
await api.storage.kv.set('ui.lastQuery', 'postgres')
await api.storage.kv.delete('ui.lastQuery')
const keys = await api.storage.kv.keys()
// ③ 自建表:SQL 自由发挥
await api.storage.db.exec('CREATE TABLE IF NOT EXISTS notes (...)') // DDL,激活期执行
await api.storage.db.run('INSERT INTO notes (id, title) VALUES (?, ?)', ['n1', '标题'])
const rows = await api.storage.db.query('SELECT * FROM notes WHERE title LIKE ?', ['%标%'])
// 原子批量:worker 端 BEGIN/COMMIT,任一失败整体回滚
await api.storage.db.batch([
{ sql: 'INSERT INTO notes (id, title) VALUES (?, ?)', params: ['n2', 'a'] },
{ sql: 'UPDATE notes SET title = ? WHERE id = ?', params: ['b', 'n2'] }
])
// ④ 自由文件(日志快照、导出等)
const dir = api.storage.dir() // userData/plugin-data/<id>/
})
```
## 3. schema 声明为什么是硬性要求
插件会被 **Agent 自我迭代**。迭代前 Agent 需要知道「库里有什么」:
- `declareSchema` 的内容随 `plugins:list` 下发——管理页「存储区」卡片与(未来的)Agent 内省工具读的是**同一份**数据;
- 每张表、每个字段的 `description` 必填,`plugin:check` 会拒绝「用了 db 却不声明 schema」的插件;
- 写给 Agent 读的 description 请具体(「创建时间,Unix 毫秒」而不是「时间」)。
## 4. 迁移(表结构变更)
宿主不解释 schema,迁移由插件自管,惯用法:
```ts
const row = await api.storage.db.query('PRAGMA user_version')
const v = Number(row[0]?.user_version ?? 0)
if (v < 1) {
await api.storage.db.exec('ALTER TABLE notes ADD COLUMN tag TEXT')
await api.storage.db.exec('PRAGMA user_version = 1')
}
```
## 5. 设置页管理
- 二级页「存储区」卡片:schema 表格化展示(表/列/类型/描述)+ **数据目录**(资源管理器打开)+ **清除数据**(二次确认,连接关闭后整目录删除)。
## 6. 边界
- 存储只在 **main 层**提供;renderer 组件 / iframe 页需要数据时,让 main 侧经状态胶囊/设置值转发(受控 IPC 通道属后续契约)。
- KV 的 value 必须可 JSON 序列化(Date/Map 请先转换);大二进制放文件,路径存 KV。
- 跨插件**不直读对方库**:需要别人的数据 = 声明依赖 + 走对方服务(见 L8)。
+174
View File
@@ -0,0 +1,174 @@
# DESIGN.md · 插件视觉一致性规范(beautifului 审美)
> 读者是**写 UI 的 Agent/开发者**。目标:任何来源的插件,界面观感与宿主完全一致,
> 达到 [beautifului.dev](https://www.beautifului.dev/)(MIT)的质感。
> 规则分两类:**强制**(可机器校验/一眼可辨)与**默认**(无充分理由不得偏离)。
> 契约问题看 [../plugins.md](../plugins.md);本文件只管"长什么样"。
> Token 单一事实源:`apps/desktop/src/renderer/src/styles/beautifului.css`(= foundation 原版),
> 组件参考源码归档在 `docs/ui-ref/beautifului/*.json`(26 个 registry)。
## 0. 两条铁律
1. **色值只允许来自 token**。任何 `#hex`/`rgb()`/`oklch()` 硬编码(规范中列出的除外)都算违规;
语义永远用 token 名表达(`text-ink-3` 而不是 `text-gray-400`)。
2. **不发明新组件形态**。先查本文 §4 标准模式与公共组件库;确需新形态时,
用 §1 的 token 组合拼装,不引入新色/新圆角/新阴影。
## 1. Token 表(单一事实源:`peer://assets/tokens.css` = 宿主 beautifului.css)
### 表面(背景)——冷调近白,亮暗两套完整 token
| token | 意图 |
|---|---|
| `--page` | 页面最底层背景 |
| `--canvas` | 画布(primary 按钮的文字色取这里) |
| `--surface` | 卡片/面板表面(纯白) |
| `--inset` | 内嵌区域(输入框底、预览底、代码块底) |
| `--hover` / `--hover-2` | 悬停态一级/二级 |
### 文字——中性 ink ramp(深灰,不用纯黑)
| token | 意图 |
|---|---|
| `--ink` | 主文字(标题、关键值) |
| `--ink-2` | 次级文字(正文说明) |
| `--ink-3` | 弱化文字(占位、提示、元信息、路径) |
### 边线——实色 hairline,不用 alpha
| token | 意图 |
|---|---|
| `--line` | 常规边框(卡片、列表分隔) |
| `--line-strong` | 强边框(输入框、可点击描边) |
| `--line-soft` | 极弱分隔 |
### 强调与语义——蓝色 accent,语义色只作"调味料"
| token | 意图 |
|---|---|
| `--accent` / `--accent-ink` / `--accent-tint` | 焦点环/选区/选中态/链接(accent-ink 负责可交互文本,保 AA 对比) |
| `--green` / `--green-tint` | 成功/正常 |
| `--orange` / `--orange-tint` | 警示 |
| `--red` / `--red-tint` | 错误/危险 |
### 阴影——「ring + 分层柔影」,不是纯 hairline
| token | 意图 |
|---|---|
| `--shadow-btn` | ring(line-strong) + xs——secondary 按钮 |
| `--shadow-card` | ring(line) + sm——卡片 |
| `--shadow-raised` | ring(line) + md——悬浮件 |
| `--shadow-overlay` | ring(line) + lg——弹层/模态 |
| `--shadow-inset-field` | 输入框内嵌阴影 |
| `--shadow-hairline` | 纯 ring(无投影)|
暗色模式 ring 自动切换为低透明白渐进(0.10→0.15 按浮起高度),阴影加深——
**黑白主题不是简单反色**:暗色有一套专属 elevation 表达。
### 圆角体系(关键差异)
| token | 值 | 用途 |
|---|---|---|
| `--radius-chip` | 6px | chips、小徽标 |
| `--radius-control` | 8px | 输入框、菜单行、icon 按钮 |
| `--radius-card` | 10px | 卡片 |
| `--radius-window` | 14px | 窗口级容器(composer 外壳、弹窗) |
| `rounded-full` | 999px | **按钮(pill 是核心语言)**、状态点、头像 |
### 动效——强缓动曲线,内置 ease 太弱
| token | 值 | 用途 |
|---|---|---|
| `--ease-out-strong` | cubic-bezier(0.23, 1, 0.32, 1) | 大位移(滑层、展开、fade-up) |
| `--ease-in-out-strong` | cubic-bezier(0.77, 0, 0.175, 1) | 往返位移 |
| `--ease-link` | cubic-bezier(0.16, 1, 0.3, 1) | 下划线/微交互 |
| 控件默认 | 150ms ease-out | pill 按钮、悬停反馈 |
| 按压 | `active:scale-[0.96]` | 所有可点 pill |
## 2. 类名词汇(宿主 Tailwind 映射,renderer 组件直接用)
背景 `bg-page / bg-canvas / bg-surface / bg-inset / bg-hover / bg-hover-2 / bg-accent / bg-accent/10` ·
文字 `text-ink / text-ink-2 / text-ink-3 / text-accent / text-accent-ink / text-red` ·
边框 `border-line / border-line-strong / border-dashed border-line` ·
圆角 `rounded-chip`(6)`rounded-control`(8)`rounded-card`(10)`rounded-window`(14)`rounded-full`(pill)·
阴影 `shadow-btn / shadow-card / shadow-raised / shadow-overlay`。
## 3. 字号与间距
- 基准 14px/1.5,`letter-spacing -0.01em`;字号阶:`11px`(元信息/徽标)→ `12px`(chips、正文说明)→ `12.5/13px`(行标题、按钮)→ `14px`(正文)→ `16px`(页面标题)。**不使用其他字号**。
- 间距基数 4px:卡片内边距 `px-3 py-2.5`(紧凑)或 `primitive-card-pad`(12px),区块间 `space-y-1.5`,小节间 `pt-3`。
- 图标 12–16px 线条风格,`currentColor`;**一律 lucide**(宿主白名单 `plugin-icons.tsx`),禁止自绘 svg。
- 颜色只作"调味料":结构永远是中性 ink + line,彩色仅出现在状态/数值/选中。
## 4. 标准模式
> **renderer 组件优先用组件库**:`@aso/plugin-sdk/ui` 的 `Button`/`SettingRow`/`LoadingState`/
> `ErrorState`/`EmptyState`/`Badge`… 已按本规范实现(见 [L6](./07-ui-components.md))。
> 下面的 HTML 形态留给 iframe 插件页作者。
### 按钮(pill 是核心语言)
```html
<!-- primary:墨底 canvas 字 + 内高光;accent:蓝底白字;secondary:白底 ring+xs -->
<button class="inline-flex h-[27px] items-center justify-center gap-1.5 rounded-full px-3
text-[13px] font-medium leading-none transition-[transform,background-color,opacity]
duration-150 ease-out active:scale-[0.96] bg-accent text-white hover:bg-accent-ink
shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]">主操作</button>
<!-- quiet:透明直到悬停(密集工具行) -->
<button class="h-7 rounded-full px-2.5 text-[12px] text-ink hover:bg-hover">次要</button>
```
### 设置行(label 左 / 控件右)
```html
<div class="flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5">
<div class="min-w-0">
<div class="text-[12.5px] text-ink">标题</div>
<div class="mt-0.5 text-[11px] leading-4 text-ink-3">说明文字</div>
</div>
<!-- 控件 -->
</div>
```
### 卡片(window 级容器用 14px)
```html
<div class="rounded-window border border-line bg-surface shadow-card">…</div>
```
### 徽标 / 空态 / 开关
徽标 `rounded-chip bg-hover px-1.5 py-0.5 text-[10px] text-ink-3`;
空态 = 虚线卡(同旧版);开关 `h-5 w-9`(行内)/ `h-6 w-10`(表单级),
滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。
## 5. 动效范式
- 悬停反馈:150ms ease-out,只变 background-color/text(配 `transition-colors`)
- 内容进场:`fade-up 400ms var(--ease-out-strong)`(聊天消息、卡片)
- 弹层:`pop-in`(scale 0.95→1)+ `shadow-overlay`
- 滑动高亮层(菜单/标签条):220ms `cubic-bezier(0.23,1,0.32,1)`(GlideMenu)
- 流式输出:光标 2px `--ink` 常亮→结束后闪烁;`prefers-reduced-motion` 全局降级
## 6. iframe 插件页附加规则
- `<head>` 必须引入 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind
- 主题跟随宿主推送(`useHarnessTheme`),**禁止** `prefers-color-scheme` 自测;亮暗两套 token 都要核对
- 与宿主的视觉差异应趋近于零:同 token + 同类名词汇 + 同字号阶 + 同圆角/阴影体系
## 7. renderer 组件附加规则
- 类名直接用宿主词汇(§2),无需引样式;**优先 import 公共组件库**([L6](./07-ui-components.md)),不要手写标准模式
- React 必须来自 `ctx.react`(见 [L4](./05-renderer-components.md)),否则 hooks 失效
- 禁止操作宿主 DOM(document 全局查询/改样式),一切交互限制在自己的组件子树
- 机器关卡:`pnpm plugin:check <插件目录>`(hex 色值 / React 副本 / 三态缺失等,见 [L7](./08-performance.md))
## 8. 自检清单(提发布前)
- [ ] `pnpm plugin:check <插件目录>` 0 error(机器关卡,见 [L7](./08-performance.md))
- [ ] 无硬编码色值/阴影/圆角(铁律 1)
- [ ] 圆角落在体系上:chip 6 / control 8 / card 10 / window 14 / pill 999
- [ ] 字号都在 §3 的阶上;按钮是 pill(rounded-full)
- [ ] 深浅色都检查过(暗色不是反色:ring 是低透白、阴影更深)
- [ ] 复用了 §4 模式(renderer 直接用组件库)而非新造形态
- [ ] iframe 页引了 tokens.css 且无 `prefers-color-scheme`
+41
View File
@@ -0,0 +1,41 @@
# 插件开发教程(渐进式)
> 面向「写插件的人(含 Agent)」。从零到高级共 8 篇,每篇 10-30 分钟,逐篇递进、可独立验证。
> 契约细节(类型/字段全集)见 [../plugins.md](../plugins.md);UI 规范见 [DESIGN.md](./DESIGN.md)。
## 学习路径
| 篇 | 内容 | 产出 |
|---|---|---|
| [L0 第一个插件](./01-hello-plugin.md) | manifest + main.mjs,装载/启停/日志 | 侧栏可见、日志可查的最小插件 |
| [L1 工具与命令](./02-tools-and-commands.md) | 给 Agent 注册工具、斜杠命令、事件 | Agent 能力扩展 |
| [L2 设置表单](./03-settings.md) | 声明式 schema、13 种标准控件、一级入口 | 用户可配置的插件 |
| [L3 UI 槽位](./04-ui-slots.md) | 状态胶囊/工具栏按钮/视图/面板 tab/iframe 页 | 界面存在感 |
| [L4 React 组件](./05-renderer-components.md) | entry.renderer:宿主内 React 组件 + JSX 构建配方 | 一等公民级 UI |
| [L5 自诊断](./06-self-diagnosis.md) | 日志规范、排障闭环、Agent 自迭代约定 | 可被 Agent 维护的插件 |
| [L6 公共组件库](./07-ui-components.md) | @aso/plugin-sdk/ui:统一三态(等待/失败/空)与基础控件 | 与宿主零差异的 UI |
| [L7 性能与检验](./08-performance.md) | 主线程不阻塞、超时重试、plugin:check 机器关卡 | 可发布、不卡宿主的插件 |
| [L8 依赖与互调](./10-dependencies.md) | dependencies 声明、拓扑激活、api.service/requirePlugin | 可组合、可协作的插件 |
| [L9 存储](./11-storage.md) | 每插件 SQLite 库 + KV + schema 声明 + 数据管理 | 有持久化、可被 Agent 迭代的插件 |
> **排障中?** 直接跳 [常见错误速查](./09-troubleshooting.md)——症状 → 原因 → 修复三段式速查表。
## 30 秒总览
```
<插件目录>/ 三个可选位置(按优先级):
manifest.json 1. userData/plugins/<id>/ (应用托管)
main.mjs (可选) 2. <工作区>/.pi/extensions/<id>/ (项目级)
renderer.mjs (可选) 3. ~/.pi/agent/extensions/<id>/ (全局)
index.html (可选)
```
- **main.mjs**:Agent 能力(工具/命令/事件)+ 声明设置表单 + 内联 UI 数据
- **renderer.mjs**:宿主内 React 组件(需构建,见 L4)
- **index.html**:iframe 插件页(物理隔离,桥接见 L3)
## 环境要求
- 第三方插件装载默认**仅开发版开启**(打包版关闭,`ASO_EXTERNAL_PLUGINS=1` 可豁免)
- 日志文件:`userData/logs/main-YYYY-MM-DD.log`(排查问题的第一入口,见 L5)
- 重载是**宿主级**动作(重扫全部插件源 + 重建运行时);改了哪个插件都点同一个「重载」
+3 -2
View File
@@ -12,7 +12,8 @@
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) | | v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 | | v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 | | v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 | | v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data/<id>/data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、悬浮状态胶囊(ZCode 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、受管浏览器代理设置(persist 分区 session.setProxy:跟随系统/直连/手动 Chromium proxyRules + 绕过列表,保存即生效并对 browser_* 工具同样生效)、图标统一 lucide 白名单、面板平铺标签条 |
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
--- ---
@@ -28,7 +29,7 @@
**自省**(v0.8.0 → 指导 M4+): **自省**(v0.8.0 → 指导 M4+):
1. **外置插件尚未强制签名**——当前 `userData/plugins/` 直接装载(代码里已留 TODO);M5 验签落地前不对外分发任何外置插件。 1. **外置插件尚未强制签名**——当前 `userData/plugins/` 直接装载(代码里已留 TODO);M5 验签落地前不对外分发任何外置插件。
2. **B3/B4/B5 槽位数据已通、渲染未接**——状态胶囊已在 TitleBar 生效;Composer 工具行按钮、聊天流围栏块元组件(data-table 等)、聊天区浮动胶囊排 M4 同批或下版本。 2. **B4/B5 槽位数据已通、渲染未接**——状态胶囊已升级为悬浮胶囊(B3 落地:药丸⇄面板);Composer 工具行按钮、聊天流围栏块元组件(data-table 等)排 M4 同批或下版本。
3. **切换回聊天未自动聚焦输入框**——保活后焦点丢失,需 Composer 暴露 focus 入口(data 属性 + App effect)。 3. **切换回聊天未自动聚焦输入框**——保活后焦点丢失,需 Composer 暴露 focus 入口(data 属性 + App effect)。
4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。 4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。
5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。 5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "button",
"type": "registry:ui",
"title": "Button",
"description": "Shared building block used by other Beautiful UI components.",
"dependencies": [
"class-variance-authority"
],
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/Button.tsx",
"content": "\"use client\";\n\nimport { ButtonHTMLAttributes } from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst filledShadow = \"shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]\";\n\n/* Pill-shaped by default — the app's core button style. Explicit symmetric\n * padding (not a fixed height) so the top/bottom spacing is always equal. */\nexport const buttonVariants = cva(\n `inline-flex items-center justify-center font-medium select-none\n transition-[transform,background-color,opacity] duration-150 ease-out\n active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,\n {\n variants: {\n variant: {\n primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,\n secondary: \"bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover\",\n ghost: \"bg-hover-2 text-ink hover:bg-line-strong\",\n accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,\n success: `bg-green text-white hover:brightness-95 ${filledShadow}`,\n /* transparent until hovered — for dense toolbars/action rows */\n quiet: \"text-ink hover:bg-hover\",\n },\n size: {\n /* compact toolbar pill — fixed height, lighter weight */\n xs: \"h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1\",\n /* canonical action pill — 27px tall, roomy sides */\n sm: \"h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5\",\n md: \"px-4 py-[9px] text-sm leading-none rounded-full gap-2\",\n },\n },\n defaultVariants: { variant: \"secondary\", size: \"md\" },\n },\n);\n\nexport type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>[\"variant\"]>;\n\nexport function Button({\n variant,\n size,\n className,\n ...props\n}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {\n return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;\n}\n",
"type": "registry:ui",
"target": "components/atoms/Button.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "context-cards",
"type": "registry:component",
"title": "Context Cards",
"description": "Retrieved knowledge chunks with their sources.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/primitives/ContextCards.tsx",
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * CONTEXT CARDS\n * Retrieved chunks enter once, then remain available.\n * ───────────────────────────────────────────────────────── */\n\nexport type ContextChunk = {\n title: string;\n chars: string;\n body: string;\n source: string;\n badge: string;\n tone: string;\n};\n\nexport type ContextCardsLabels = {\n header: string;\n count: string;\n};\n\nconst DEFAULT_LABELS: ContextCardsLabels = {\n header: \"All chunks\",\n count: \"32\",\n};\n\nconst CHUNKS: ContextChunk[] = [\n {\n title: \"Vendor onboarding rule\",\n chars: \"290 characters\",\n body: \"Cold-chain certification must be verified before a new dairy can be added to the reorder workflow.\",\n source: \"Dairy Onboarding SOP.pdf\",\n badge: \"PDF\",\n tone: \"bg-red\",\n },\n {\n title: \"Seasonal demand row\",\n chars: \"1,250 characters\",\n body: \"Q4 velocity table: pistachio +18%, vanilla +6%, rocky road -11%; retire flavors below 40 scoops weekly.\",\n source: \"Sales Velocity Export.csv\",\n badge: \"CSV\",\n tone: \"bg-green\",\n },\n];\n\nexport default function ContextCards({\n chunks = CHUNKS,\n labels,\n className,\n}: {\n /** Accepted for gallery/registry parity; ContextCards has no visual variants. */\n variant?: string;\n chunks?: ContextChunk[];\n labels?: Partial<ContextCardsLabels>;\n className?: string;\n} = {}) {\n const [chipsShown, setChipsShown] = useState(false);\n const copy = { ...DEFAULT_LABELS, ...labels };\n\n useEffect(() => {\n const chips = setTimeout(() => setChipsShown(true), 700);\n return () => clearTimeout(chips);\n }, []);\n\n return (\n <div className={`flex w-full max-w-95 flex-col gap-2${className ? ` ${className}` : \"\"}`}>\n <div\n className=\"flex items-center gap-2 px-0.5\"\n style={{ animation: \"fade-in 400ms ease-out both\" }}\n >\n <span className=\"text-[13px] font-semibold text-ink\">{copy.header}</span>\n <span className=\"inline-flex h-5 items-center rounded-md bg-inset px-1.5 text-[11.5px] font-medium text-ink-2 shadow-hairline tabular-nums\">\n {copy.count}\n </span>\n </div>\n\n {chunks.map((chunk, i) => (\n <div\n key={chunk.title}\n className=\"overflow-hidden rounded-card bg-surface shadow-card\"\n style={{\n animation: `fade-up 400ms cubic-bezier(0.23,1,0.32,1) ${i * 100}ms both`,\n }}\n >\n <div className=\"primitive-card-bar flex items-center gap-2.5 border-b border-line\">\n <span className=\"flex min-w-0 items-center gap-1.5 text-[13px] font-medium text-ink\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\"><path d=\"M4 6h16M4 12h16M4 18h10\" /></svg>\n <span className=\"truncate\">{chunk.title}</span>\n </span>\n <span className=\"ml-auto shrink-0 text-[12px] text-ink-3 tabular-nums\">{chunk.chars}</span>\n </div>\n <p className=\"px-3 pt-2 pb-1 text-[12.5px] leading-relaxed text-ink-2\">\n {chunk.body}\n </p>\n <div className=\"px-3 pb-3\">\n <span\n className=\"inline-flex h-6 items-center gap-1.5 rounded-full bg-inset px-2\n text-[12px] font-medium text-ink-2 shadow-btn\n transition-[opacity,transform,background-color] duration-300 hover:bg-hover\"\n style={{\n opacity: chipsShown ? 1 : 0,\n transform: chipsShown ? \"scale(1)\" : \"scale(0.95)\",\n transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n transitionDelay: `${i * 80}ms`,\n }}\n >\n <span className={`flex size-3.5 items-center justify-center rounded-[4px] ${chunk.tone} text-[7px] font-bold text-white`}>\n {chunk.badge}\n </span>\n {chunk.source}\n <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M7 17L17 7M7 7h10v10\" /></svg>\n </span>\n </div>\n </div>\n ))}\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/ContextCards.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "entity-chip",
"type": "registry:ui",
"title": "EntityChip",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/EntityChip.tsx",
"content": "/** Monogram mark — a colored disc with an initial or short glyph.\n * The shared building block for entity chips and monogram headings. */\nexport function Monogram({\n children,\n color = \"#e08a3c\",\n className = \"\",\n}: {\n children: React.ReactNode;\n color?: string;\n className?: string;\n}) {\n return (\n <span\n className={`flex size-4 shrink-0 items-center justify-center rounded-full\n text-[9px] font-semibold leading-none text-white ${className}`}\n style={{ background: color }}\n >\n {children}\n </span>\n );\n}\n\n/** Inline entity reference — a monogram + name in a soft field pill.\n * Names a supplier, person, or record inside running text. Softer than a\n * StatusPill (no dot, no state) and not a mono token (see Chip). */\nexport function EntityChip({\n name,\n color,\n monogram,\n className = \"\",\n}: {\n name: string;\n color?: string;\n monogram?: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`mx-0.5 inline-flex items-center gap-1 rounded-full bg-field\n py-px pl-[3px] pr-1.5 align-middle shadow-hairline ${className}`}\n >\n <Monogram color={color}>{monogram ?? name.charAt(0)}</Monogram>\n <span className=\"text-[12px] font-medium text-ink\">{name}</span>\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/EntityChip.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "glide-menu",
"type": "registry:ui",
"title": "GlideMenu",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/primitives/GlideMenu.tsx",
"content": "\"use client\";\n\nimport { useRef, useState, type ReactNode } from \"react\";\n\ntype GlideMenuProps = {\n children: ReactNode;\n className?: string;\n highlightClassName?: string;\n rowSelector?: string;\n};\n\n/** A single hover layer that glides between interactive menu rows. */\nexport default function GlideMenu({\n children,\n className = \"\",\n highlightClassName = \"inset-x-0 rounded-[8px] bg-hover\",\n rowSelector = \"[data-menu-row]\",\n}: GlideMenuProps) {\n const ref = useRef<HTMLDivElement>(null);\n const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n const [visible, setVisible] = useState(false);\n\n const moveTo = (target: EventTarget | null) => {\n const container = ref.current;\n if (!(target instanceof Element) || !container) return;\n const row = target.closest(rowSelector);\n if (!(row instanceof HTMLElement) || !container.contains(row)) return;\n const containerRect = container.getBoundingClientRect();\n const rowRect = row.getBoundingClientRect();\n setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });\n setVisible(true);\n };\n\n return (\n <div\n ref={ref}\n onMouseOver={(event) => moveTo(event.target)}\n onMouseLeave={() => setVisible(false)}\n onFocusCapture={(event) => moveTo(event.target)}\n onBlurCapture={(event) => {\n if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);\n }}\n className={`group/glide-menu relative ${className}`}\n >\n <span\n aria-hidden\n className={`pointer-events-none absolute ${highlightClassName}`}\n style={{\n top: box?.top ?? 0,\n height: box?.height ?? 0,\n opacity: box && visible ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {children}\n </div>\n );\n}\n",
"type": "registry:ui",
"target": "components/primitives/GlideMenu.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "search",
"type": "registry:component",
"title": "Search",
"description": "Command search with live filtering and an empty state.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json",
"https://www.beautifului.dev/r/glide-menu.json"
],
"files": [
{
"path": "components/primitives/SearchList.tsx",
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport GlideMenu from \"@/components/primitives/GlideMenu\";\n\n/* ─────────────────────────────────────────────────────────\n * SEARCH — command search with live filtering.\n * The field, clear action, and results are directly usable.\n * ───────────────────────────────────────────────────────── */\n\nexport type SearchItem = string;\n\nexport type SearchListLabels = {\n placeholder: string;\n ariaLabel: string;\n emptyTitle: string;\n emptyHint: string;\n};\n\nconst ITEMS: SearchItem[] = [\n \"Forecast summer demand\",\n \"Find waffle cone suppliers\",\n \"Compare seasonal flavors\",\n \"Draft flavor launch plan\",\n \"Check cold-chain status\",\n \"Audit sugar costs\",\n \"Retire low sellers\",\n];\n\nconst LABELS: SearchListLabels = {\n placeholder: \"Search flavors…\",\n ariaLabel: \"Search flavors\",\n emptyTitle: \"No results found\",\n emptyHint: \"Adjust your search to try again\",\n};\n\nexport default function SearchList({\n items = ITEMS,\n labels = LABELS,\n}: {\n items?: SearchItem[];\n labels?: SearchListLabels;\n variant?: string;\n} = {}) {\n const [query, setQuery] = useState(\"\");\n const results = query\n ? items.filter((i) => i.toLowerCase().includes(query.toLowerCase()))\n : items.slice(0, 5);\n const empty = query.length > 2 && results.length === 0;\n\n return (\n <div className=\"flex min-h-[248px] w-full max-w-72 flex-col items-stretch\">\n <div className=\"w-full self-start overflow-hidden rounded-card bg-surface shadow-raised\">\n {/* input row */}\n <div className=\"flex h-10 items-center gap-2 border-b border-line px-3 transition-colors duration-100 hover:bg-hover\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--ink-3)\" strokeWidth=\"2\" strokeLinecap=\"round\" className=\"shrink-0\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"M21 21l-4.3-4.3\" />\n </svg>\n <input\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={labels.placeholder}\n aria-label={labels.ariaLabel}\n className=\"min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3\"\n />\n {query && (\n <button\n aria-label=\"Clear search\"\n type=\"button\"\n onClick={() => setQuery(\"\")}\n className=\"flex size-6 items-center justify-center rounded-full text-ink-3\n transition-colors duration-100 hover:bg-line/70 hover:text-ink\"\n style={{ animation: \"fade-in 150ms ease-out both\" }}\n >\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\">\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n </button>\n )}\n </div>\n\n {/* results / empty state */}\n {empty ? (\n <div className=\"flex flex-col items-center justify-center gap-1 px-4 py-8\" style={{ animation: \"fade-in 250ms ease-out both\" }}>\n <span className=\"mb-1.5 flex size-8 items-center justify-center rounded-control bg-inset text-ink-3 shadow-hairline\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"M21 21l-4.3-4.3\" />\n </svg>\n </span>\n <span className=\"text-[13px] font-medium text-ink\">{labels.emptyTitle}</span>\n <span className=\"text-[12px] text-ink-3\">{labels.emptyHint}</span>\n </div>\n ) : (\n <div className=\"p-1\">\n <GlideMenu className=\"flex flex-col gap-px\" highlightClassName=\"inset-x-0 rounded-[6px] bg-hover\">\n {results.map((item) => (\n <button\n key={item}\n data-menu-row\n type=\"button\"\n onClick={() => setQuery(item)}\n className=\"relative z-10 flex h-8 w-full items-center rounded-[6px] px-2 text-left text-[13px] text-ink\"\n style={{ animation: \"fade-in 200ms ease-out both\" }}\n >\n {item}\n </button>\n ))}\n </GlideMenu>\n </div>\n )}\n </div>\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/SearchList.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "shimmer",
"type": "registry:ui",
"title": "Shimmer",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/Shimmer.tsx",
"content": "/** Shimmering label — signals the agent is processing. */\nexport function Shimmer({\n children,\n className = \"\",\n}: {\n children: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`inline-block bg-clip-text text-transparent ${className}`}\n style={{\n backgroundImage:\n \"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)\",\n backgroundSize: \"200% 100%\",\n animation: \"shimmer-text 1.8s linear infinite\",\n }}\n >\n {children}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/Shimmer.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "stream-text",
"type": "registry:ui",
"title": "StreamText",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/StreamText.tsx",
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAM TEXT — reusable streaming primitive\n * Reveals characters quickly (fast, like a real token stream);\n * the leading edge resolves out of a soft blur, and the caret\n * stays solid while streaming, then blinks once the text\n * settles. Inherits typography from its context, so it drops\n * into any text surface (Selection Actions, chat, etc.).\n * ───────────────────────────────────────────────────────── */\n\nexport function StreamText({\n text,\n charsPerTick = 2,\n tickMs = 9,\n blurTail = 6,\n caret = true,\n className,\n onProgress,\n onDone,\n}: {\n text: string;\n /** characters revealed per tick — higher is faster */\n charsPerTick?: number;\n /** interval between reveals, ms */\n tickMs?: number;\n /** how many trailing characters carry the soft blur edge */\n blurTail?: number;\n /** render the caret (solid while streaming, blinks once idle) */\n caret?: boolean;\n className?: string;\n /** fires each tick — useful for re-anchoring UI to reflowing text */\n onProgress?: () => void;\n /** fires once the full string is shown */\n onDone?: () => void;\n}) {\n const [count, setCount] = useState(0);\n const onProgressRef = useRef(onProgress);\n const onDoneRef = useRef(onDone);\n onProgressRef.current = onProgress;\n onDoneRef.current = onDone;\n\n useEffect(() => {\n setCount(0);\n let i = 0;\n const id = setInterval(() => {\n i = Math.min(i + charsPerTick, text.length);\n setCount(i);\n onProgressRef.current?.();\n if (i >= text.length) {\n clearInterval(id);\n onDoneRef.current?.();\n }\n }, tickMs);\n return () => clearInterval(id);\n }, [text, charsPerTick, tickMs]);\n\n const streaming = count < text.length;\n const shown = text.slice(0, count);\n const split = streaming ? Math.max(0, shown.length - blurTail) : shown.length;\n\n return (\n <span className={className}>\n {shown.slice(0, split)}\n {split < shown.length && (\n <span className=\"stream-tail\">{shown.slice(split)}</span>\n )}\n {caret && (\n <span\n aria-hidden\n className={`stream-caret${streaming ? \" is-streaming\" : \"\"}`}\n />\n )}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/StreamText.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "value-pill",
"type": "registry:ui",
"title": "ValuePill",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/ValuePill.tsx",
"content": "type Tone = \"neutral\" | \"green\" | \"orange\" | \"red\" | \"accent\";\n\nconst TONES: Record<Tone, { cls: string; ring: string }> = {\n neutral: { cls: \"bg-field text-ink-2\", ring: \"var(--shadow-hairline)\" },\n green: { cls: \"bg-green-tint text-green\", ring: \"0 0 0 1px color-mix(in oklch, var(--green) 28%, transparent)\" },\n orange: { cls: \"bg-orange-tint text-orange\", ring: \"0 0 0 1px color-mix(in oklch, var(--orange) 28%, transparent)\" },\n red: { cls: \"bg-red-tint text-red\", ring: \"0 0 0 1px color-mix(in oklch, var(--red) 28%, transparent)\" },\n accent: { cls: \"bg-accent-tint text-accent-ink\", ring: \"0 0 0 1px color-mix(in oklch, var(--accent) 28%, transparent)\" },\n};\n\n/** Inline value badge — a plain value (a date, a name, a count) set off in\n * prose. Softer than a StatusPill (no dot) and not a mono token (see Chip). */\nexport function ValuePill({\n children,\n tone = \"neutral\",\n className = \"\",\n}: {\n children: React.ReactNode;\n tone?: Tone;\n className?: string;\n}) {\n const t = TONES[tone];\n return (\n <span\n className={`mx-0.5 inline-flex items-center rounded-full px-1.5 py-0\n align-middle text-[12px] font-medium ${t.cls} ${className}`}\n style={{ boxShadow: t.ring }}\n >\n {children}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/ValuePill.tsx"
}
]
}
+2 -1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"typecheck": "pnpm -r --if-present typecheck", "typecheck": "pnpm -r --if-present typecheck",
"build": "pnpm -r --if-present build", "build": "pnpm -r --if-present build",
"dev": "pnpm --dir apps/desktop dev" "dev": "pnpm --dir apps/desktop dev",
"plugin:check": "node scripts/check-plugin.mjs"
} }
} }
+3 -1
View File
@@ -6,7 +6,9 @@
"description": "Inkreach harness 插件契约:清单/主进程工厂类型 + definePlugin + 插件页桥接 client(主入口给 main.mjs 作者,./client 给 iframe 页作者)", "description": "Inkreach harness 插件契约:清单/主进程工厂类型 + definePlugin + 插件页桥接 client(主入口给 main.mjs 作者,./client 给 iframe 页作者)",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./client": "./src/page.ts" "./client": "./src/page.ts",
"./renderer": "./src/renderer.ts",
"./ui": "./src/ui.tsx"
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
+68
View File
@@ -0,0 +1,68 @@
/**
* 插件异步工具:超时与重试(零依赖,主进程 main.mjs 与 renderer 通用)。
*
* 性能铁律(docs/plugins/08-performance.md):插件内一切可能挂起/失败的异步操作
* (网络请求、子进程、文件轮询…)都应经 withTimeout / withRetry 包裹。宿主对插件
* 激活(10s)与 renderer 装载(8s)有兜底超时——超时即判失败,不会无限等;
* 插件自己的异步调用应先于宿主超时并自行恢复,而不是被宿主判死。
*/
/** 超时错误(err.name === 'TimeoutError',可据此与真实业务失败区分) */
export class TimeoutError extends Error {
constructor(message: string) {
super(message)
this.name = 'TimeoutError'
}
}
/**
* 给 Promise 加超时;超时后以 TimeoutError 拒绝。
* 注意:底层操作不会被取消(Promise 无 cancel 语义),有 AbortController 的调用方
* 应自行把 signal 接到底层,再用本函数兜底计时。
*/
export function withTimeout<T>(promise: Promise<T>, ms: number, label?: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new TimeoutError(`${label ?? '操作'} 超时(${ms}ms)`))
}, ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export interface RetryOptions {
/** 失败后的额外重试次数(总尝试 = retries + 1),默认 2 */
retries?: number
/** 首次重试前的等待,默认 300ms;之后指数退避(×2) */
delayMs?: number
/** 单次尝试的超时上限(ms),经 withTimeout 计时 */
timeoutMs?: number
/** 每次重试前的回调(记日志/上报用) */
onRetry?: (err: unknown, attempt: number, nextDelayMs: number) => void
}
/**
* 带超时与指数退避的重试:fn 返回 reject 或超时都算失败,重试耗尽后抛最后一次的错误。
*/
export async function withRetry<T>(fn: (attempt: number) => Promise<T>, options: RetryOptions = {}): Promise<T> {
const retries = options.retries ?? 2
const delayMs = options.delayMs ?? 300
let lastErr: unknown
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const run = fn(attempt)
return options.timeoutMs !== undefined ? await withTimeout(run, options.timeoutMs, `第 ${attempt + 1} 次尝试`) : await run
} catch (err) {
lastErr = err
if (attempt === retries) break
const nextDelay = delayMs * 2 ** attempt
options.onRetry?.(err, attempt + 1, nextDelay)
await sleep(nextDelay)
}
}
throw lastErr
}
+3
View File
@@ -3,5 +3,8 @@
* 插件页(iframe)请改用 '@aso/plugin-sdk/client' 子入口(不含 pi SDK 类型依赖路径)。 * 插件页(iframe)请改用 '@aso/plugin-sdk/client' 子入口(不含 pi SDK 类型依赖路径)。
*/ */
export * from './manifest' export * from './manifest'
export * from './settings'
export * from './async'
export * from './status' export * from './status'
export * from './storage'
export * from './plugin' export * from './plugin'
+10 -1
View File
@@ -15,7 +15,7 @@ export interface PluginManifest {
/** 最低 harness 契约版本(semver),不满足拒载 */ /** 最低 harness 契约版本(semver),不满足拒载 */
minContract: string minContract: string
description?: string description?: string
/** 侧栏入口图标:svg 字符串;缺省用宿主默认字形 */ /** 侧栏入口图标:lucide 图标名(如 "Globe",白名单见 docs/plugins/DESIGN.md);兼容旧内联 svg 字符串;缺省/未知名回落拼图图标 */
icon?: string icon?: string
/** 侧栏入口排序(升序,只在插件之间排序;核心入口恒在前) */ /** 侧栏入口排序(升序,只在插件之间排序;核心入口恒在前) */
order?: number order?: number
@@ -28,10 +28,19 @@ export interface PluginManifest {
page?: string page?: string
/** 主进程模块(动态 import,默认导出 definePlugin 工厂) */ /** 主进程模块(动态 import,默认导出 definePlugin 工厂) */
main?: string main?: string
/** 渲染层模块(宿主动态 import `plugin://<id>/<renderer>`,
* 默认导出 mount(ctx);React 组件用宿主实例(ctx.react),见 renderer 契约。
* 命名勿用 index.ts/index.js——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载) */
renderer?: string
} }
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明, /** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
* 防止带原生视图能力的内置被页面级外置静默顶掉) */ * 防止带原生视图能力的内置被页面级外置静默顶掉) */
replacesNative?: boolean replacesNative?: boolean
/** 必需依赖:插件 id → semver 范围(^x.y.z / ~x.y.z / >=x.y.z / 精确 x.y.z / *)。
* 缺失 / 已停用 / 版本不符 → 本插件激活失败并提示;api.requirePlugin 仅对声明过的依赖放行 */
dependencies?: Record<string, string>
/** 可选依赖:同范围语法;缺失或停用只降级不报错(requirePlugin 返回 undefined) */
optionalDependencies?: Record<string, string>
/** 更新索引地址(JSON;格式见 docs/plugins.md) */ /** 更新索引地址(JSON;格式见 docs/plugins.md) */
updateURL?: string updateURL?: string
} }
+28 -5
View File
@@ -4,13 +4,17 @@
*/ */
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
import type { PluginManifest } from './manifest' import type { PluginManifest } from './manifest'
import type { PluginSettingsSchema, PluginSettingValues } from './settings'
import type { StatusItem, ToolbarButtonDef } from './status' import type { StatusItem, ToolbarButtonDef } from './status'
import type { PluginStorage } from './storage'
/** 插件私有 KV 存储(主进程落盘 userData/aso-plugin-storage.json,按 pluginId 命名空间隔离) */ /** 插件日志通道:宿主注入,写入统一日志文件(userData/logs/,带 pluginId 字段)。
export interface PluginStorage { * Agent 自诊断依赖该通道——插件应当把装载/激活/关键决策与失败都记到这里。 */
get<T>(key: string): T | undefined export interface PluginLogger {
set(key: string, value: unknown): void debug(message: string, kv?: Record<string, unknown>): void
delete(key: string): void info(message: string, kv?: Record<string, unknown>): void
warn(message: string, kv?: Record<string, unknown>): void
error(message: string, kv?: Record<string, unknown>): void
} }
/** harness 侧 UI 能力(Electron 版槽位,语义对位 pi TUI 的 ctx.ui.*)。 /** harness 侧 UI 能力(Electron 版槽位,语义对位 pi TUI 的 ctx.ui.*)。
@@ -24,6 +28,15 @@ export interface HarnessUiApi {
/** 注册输入区工具行按钮(按 id 键控;dispose 自动清理) */ /** 注册输入区工具行按钮(按 id 键控;dispose 自动清理) */
registerToolbarButton(button: ToolbarButtonDef): void registerToolbarButton(button: ToolbarButtonDef): void
removeToolbarButton(id: string): void removeToolbarButton(id: string): void
/** 声明插件设置表单(声明式 schema;items 为空 = 无配置页,一级入口开关随之不出现)。
* 值由宿主持久化并广播;每次插件激活时重新声明(重载后生效)。 */
registerSettings(schema: PluginSettingsSchema): void
/** 读取当前设置值(schema 默认值已合并) */
getSettingValues(): PluginSettingValues
/** 设置值变化推送(用户在设置页修改后实时触发) */
onSettingsChange(listener: (values: PluginSettingValues) => void): () => void
/** 设置页按钮条目点击回调(key = 按钮项的 key) */
onSettingsAction(listener: (key: string) => void): () => void
} }
/** 插件工厂收到的完整 API */ /** 插件工厂收到的完整 API */
@@ -31,8 +44,18 @@ export interface HarnessPluginApi {
/** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */ /** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */
pi: ExtensionAPI pi: ExtensionAPI
ui: HarnessUiApi ui: HarnessUiApi
/** 私有存储:每插件一个 SQLite 库(KV + 自建表 + 文件目录),API 全 async */
storage: PluginStorage storage: PluginStorage
/** 结构化日志(scope=plugin,自动带 pluginId;error 级别会进日志文件) */
log: PluginLogger
manifest: PluginManifest manifest: PluginManifest
/** 注册跨插件服务(按名键控;dispose/重载时宿主自动清空)。
* 只有在 manifest 中声明了本插件为依赖的插件才能取用。 */
service(name: string, impl: unknown): void
/** 取依赖插件的服务对象({ serviceName: impl })。
* 未在 dependencies / optionalDependencies 声明 targetId → 抛错(依赖声明即访问授权);
* 必需依赖未就绪 → 抛错;可选依赖未就绪 → 返回 undefined。 */
requirePlugin(targetId: string): Record<string, unknown> | undefined
} }
/** 插件工厂:同步或异步返回 dispose */ /** 插件工厂:同步或异步返回 dispose */
+38
View File
@@ -0,0 +1,38 @@
/**
* 渲染层模块契约(entry.renderer,宿主渲染层动态 import 后调用默认导出 mount)。
* 与 main.mjs 契约并列:main 管 Agent 能力(pi),renderer 管宿主内 React UI。
*
* 单实例约束:组件必须使用 ctx.react(= 宿主 React)创建,插件自带 React 副本
* 会导致 hooks 全部失效。JSX 工程的构建配方见 docs/plugins.md(esbuild react 别名 shim)。
*/
import type { ComponentType } from 'react'
import type { PluginManifest } from './manifest'
/** 渲染层可用的注册槽位(每个插件各槽位一个组件,重复注册覆盖) */
export interface PluginRendererSlots {
/** 整页视图(侧栏入口点击后的主内容区,与内置组件同槽位) */
registerView(component: ComponentType): void
/** 右侧 SidePanel tab 内容(需 manifest.panel: true) */
registerPanel(component: ComponentType): void
}
export interface PluginRendererContext extends PluginRendererSlots {
manifest: PluginManifest
/** 宿主 React 命名空间(window.__ASO_PLUGIN_HOST__.react 同一份实例) */
react: unknown
}
/** renderer.mjs 默认导出:mount(ctx),同步执行注册后返回 */
export type PluginRendererModule = (ctx: PluginRendererContext) => void
/** 身份函数:为插件作者提供完整类型推断 */
export function defineRenderer(module: PluginRendererModule): PluginRendererModule {
return module
}
/** 宿主注入到 window.__ASO_PLUGIN_HOST__ 的全局(插件构建期 react 别名 shim 消费) */
export interface PluginHostGlobal {
react: unknown
/** react/jsx-runtime(esbuild --jsx=automatic 时 shim 消费) */
jsxRuntime: unknown
}
+157
View File
@@ -0,0 +1,157 @@
/**
* 插件设置契约(声明式 schema):插件在 main.mjs 里经 api.ui.registerSettings(schema)
* 声明配置项,宿主负责渲染表单、持久化值(userData/aso-plugin-values.json,按插件隔离),
* 并把变更实时推回插件(ui.onSettingsChange)。按钮类条目的点击经 ui.onSettingsAction
* 回到插件主进程侧执行。
*
* schema 是纯数据(不可含函数/React 元素),保证主进程可持久化快照、渲染层零信任渲染。
*/
import type { PluginManifest } from './manifest'
/** 文件/目录选择模式(path / image 共用) */
export type PluginPathMode = 'file' | 'directory' | 'image'
/** 单个配置项(discriminated union on type) */
export type PluginSettingItem =
| {
/** 单行输入框;password: true 时掩码显示 */
type: 'string'
key: string
label: string
placeholder?: string
password?: boolean
default?: string
description?: string
}
| {
/** 多行文本 */
type: 'text'
key: string
label: string
placeholder?: string
rows?: number
default?: string
description?: string
}
| {
/** 数字输入 */
type: 'number'
key: string
label: string
min?: number
max?: number
step?: number
default?: number
description?: string
}
| {
/** 开关 */
type: 'boolean'
key: string
label: string
default?: boolean
description?: string
}
| {
/** 下拉单选 */
type: 'select'
key: string
label: string
options: Array<{ value: string; label: string }>
default?: string
description?: string
}
| {
/** 多选(checkbox 组) */
type: 'multiSelect'
key: string
label: string
options: Array<{ value: string; label: string }>
default?: string[]
description?: string
}
| {
/** 滑条(min/max 必填) */
type: 'slider'
key: string
label: string
min: number
max: number
step?: number
default?: number
description?: string
}
| {
/** 颜色选择(#rrggbb) */
type: 'color'
key: string
label: string
default?: string
description?: string
}
| {
/** 路径选择(文件 / 目录) */
type: 'path'
key: string
label: string
mode: Extract<PluginPathMode, 'file' | 'directory'>
placeholder?: string
default?: string
description?: string
}
| {
/** 图片选择(选择文件 + 缩略图预览) */
type: 'image'
key: string
label: string
default?: string
description?: string
}
| {
/** 动作按钮:点击回调到插件(ui.onSettingsAction,key 为本项 key) */
type: 'button'
key: string
label: string
/** primary 实心 / ghost 描边(缺省 ghost) */
variant?: 'primary' | 'ghost'
description?: string
}
| {
/** 静态说明文字(无 key,不产生配置值) */
type: 'info'
label: string
description?: string
}
| {
/** 分割线(label 可作为小节标题) */
type: 'divider'
label?: string
}
/** 插件设置 schema:items 为空数组 = 无配置页(一级入口开关随之不出现) */
export interface PluginSettingsSchema {
/** 配置页标题(缺省用插件名) */
title?: string
items: PluginSettingItem[]
}
/** 渲染层/插件共用的设置值快照(defaults 已合并) */
export type PluginSettingValues = Record<string, unknown>
/** 插件设置清单快照(管理页/导航消费) */
export type PluginSettingsSchemas = Record<string, PluginSettingsSchema>
export type PluginSettingsValuesMap = Record<string, PluginSettingValues>
/** 从 schema 提取默认值(宿主合并进 getSettingValues 返回) */
export function settingsDefaults(schema: PluginSettingsSchema): PluginSettingValues {
const out: PluginSettingValues = {}
for (const item of schema.items) {
if ('default' in item && item.default !== undefined) out[item.key] = item.default
}
return out
}
/** 设置页身份信息(导航项渲染用) */
export interface PluginSettingsMeta {
manifest: Pick<PluginManifest, 'id' | 'name' | 'icon' | 'description'>
}
+44 -2
View File
@@ -10,12 +10,54 @@ export interface OpenViewAction {
params?: unknown params?: unknown
} }
/** 徽标/胶囊共用色调语义 */
export type StatusTone = 'default' | 'accent' | 'success' | 'warn' | 'danger'
/** 详情弹层里的一行(数据驱动;openView 缺省为纯展示行) */
export interface StatusDetailRow {
/** svg 字符串(14px 线条风格,currentColor) */
icon?: string
label: string
/** 次级说明(label 下方小字) */
detail?: string
/** 右侧徽标文本(如 +76 -40、7/7、✓) */
trailing?: string
/** 徽标色调,缺省 default */
tone?: StatusTone
/** 整行点击的深链;缺省不可点击 */
openView?: OpenViewAction
}
/** 详情弹层分组(悬浮面板中为手风琴:默认收起只显示标题行,点击展开 rows) */
export interface StatusDetailSection {
/** 分组标题(如「计划」「进程」);缺省无标题(rows 直接平铺、不可收起) */
title?: string
/** 标题行尾徽标(如「4/4」「35分7秒」;收起态也可见——ZCode 式摘要) */
trailing?: string
/** 行尾徽标色调,缺省 default */
trailingTone?: StatusTone
rows: StatusDetailRow[]
}
/** 状态胶囊的展开详情:点击胶囊弹出宿主渲染的分组列表弹层。
* 声明了 detail 的胶囊,点击 = 展开弹层(行内 openView 负责深链);
* 未声明的胶囊保持原行为:点击 = item.openView。 */
export interface StatusDetail {
/** 弹层标题;缺省用胶囊文本 */
title?: string
sections: StatusDetailSection[]
}
/** 状态胶囊条目(TitleBar 胶囊区 / 聊天区浮动胶囊共用一份数据) */ /** 状态胶囊条目(TitleBar 胶囊区 / 聊天区浮动胶囊共用一份数据) */
export interface StatusItem { export interface StatusItem {
text: string text: string
tone?: 'default' | 'accent' | 'warn' | 'danger' /** svg 字符串(12-14px 线条风格,currentColor;缺省纯文字胶囊) */
/** 点击行为:深链;缺省不可点击 */ icon?: string
tone?: StatusTone
/** 点击行为:深链;有 detail 时被弹层取代;两者都缺省不可点击 */
openView?: OpenViewAction openView?: OpenViewAction
/** 点击展开的详情弹层(数据驱动,宿主统一渲染) */
detail?: StatusDetail
} }
/** 输入区工具行按钮 */ /** 输入区工具行按钮 */
+57
View File
@@ -0,0 +1,57 @@
/**
* 插件存储契约(0.3.0):每插件一个 SQLite 库(userData/plugin-data/<id>/data.db),
* 由宿主共享 worker 线程持有连接——API 全 async,永不阻塞主进程。
* 本模块只含类型(同时被宿主、渲染层、插件消费),不得引入 node:sqlite。
*/
/** SQLite 存储声明中的一列(description 必填——Agent 自我迭代读的就是它) */
export interface PluginStorageColumn {
name: string
/** SQLite 类型:TEXT / INTEGER / REAL / BLOB / NUMERIC */
type: string
description: string
}
/** 插件自建表声明 */
export interface PluginStorageTable {
name: string
description: string
columns: PluginStorageColumn[]
}
/** 机器可读存储 schema:UI Tooltip 与 Agent 内省同源(plugin:check 强制描述非空)。
* kv 表由宿主管理(api.storage.get/set 的底层),这里只要求描述键空间用途。 */
export interface PluginStorageSchema {
tables: PluginStorageTable[]
kv?: { description: string }
}
/** 宿主管理的 KV 表(SQLite kv 表键值对;value JSON 编码) */
export interface PluginKvStore {
get<T>(key: string): Promise<T | undefined>
set(key: string, value: unknown): Promise<void>
delete(key: string): Promise<void>
keys(): Promise<string[]>
}
/** 插件自建表的 SQL 入口(worker 线程执行) */
export interface PluginDb {
/** 多语句 DDL/脚本(无参数) */
exec(sql: string): Promise<void>
/** 写语句(INSERT/UPDATE/DELETE/DDL,带参数) */
run(sql: string, params?: unknown[]): Promise<void>
/** 查询(返回全部行) */
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>
/** 原子批量:worker 端 BEGIN/COMMIT 包裹,任一失败整体回滚 */
batch(statements: Array<{ sql: string; params?: unknown[] }>): Promise<void>
}
/** 插件私有存储(api.storage):KV + 自建表 SQLite + 自由文件目录 + schema 声明 */
export interface PluginStorage {
kv: PluginKvStore
db: PluginDb
/** 插件数据目录(userData/plugin-data/<id>/,可自由写文件;清除数据时整体删除) */
dir(): string
/** 声明机器可读存储 schema(每次激活重新声明;使用 db 前必须声明) */
declareSchema(schema: PluginStorageSchema): void
}
+290
View File
@@ -0,0 +1,290 @@
/**
* 插件公共 UI 组件库(@aso/plugin-sdk/ui)——宿主视觉规范的代码化。
*
* 仅限 renderer 组件槽位使用(组件渲染在宿主 DOM 里,直接复用宿主样式表);
* iframe 插件页是物理隔离文档,请参考 DESIGN.md §4 的 HTML 模式自行实现。
*
* 样式来源只有两类:
* - 宿主已生成的 Tailwind 类(DESIGN.md §2 类名词汇,全部经过存在性核验)
* - 语义 token 的内联变量(var(--accent) 等,用于宿主类词汇未覆盖的场合)
* 因此深浅主题自动跟随宿主,禁止出现硬编码色值。
*
* 等待/失败/空态铁律(docs/plugins/08-performance.md §4):一切异步区块必须呈现
* LoadingState / ErrorState / EmptyState 三态之一,禁止裸文本或无反馈。
*/
import { useState } from 'react'
import type { ButtonHTMLAttributes, ComponentProps, InputHTMLAttributes, ReactElement, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
/** 拼接类名(过滤 falsy;轻量版 cn,不引依赖) */
export function cn(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(' ')
}
// ---- 等待态 ----
export interface SpinnerProps {
/** 直径 px,默认 14 */
size?: number
className?: string
}
/** 旋转指示器:强调色弧 + 弱色圆环 */
export function Spinner({ size = 14, className }: SpinnerProps): ReactElement {
return (
<span
role="status"
aria-label="加载中"
className={cn('inline-block shrink-0 animate-spin rounded-full border-line-strong', className)}
style={{ width: size, height: size, borderWidth: 2, borderTopColor: 'var(--accent)' }}
/>
)
}
export interface LoadingStateProps {
/** 默认「加载中…」 */
label?: ReactNode
className?: string
}
/** 区块级等待态:任何异步内容就绪前的统一占位 */
export function LoadingState({ label = '加载中…', className }: LoadingStateProps): ReactElement {
return (
<div className={cn('flex flex-col items-center justify-center gap-2 py-10 text-center', className)}>
<Spinner />
<div className="text-[11.5px] text-ink-3">{label}</div>
</div>
)
}
// ---- 失败态 ----
export interface ErrorStateProps {
/** 默认「出错了」 */
title?: ReactNode
/** 错误详情(错误消息、排查建议),保留换行 */
description?: ReactNode
/** 提供则显示重试按钮 */
onRetry?: () => void
retryLabel?: string
className?: string
}
/** 区块级失败态:错误必须可见、可重试,禁止静默吞掉 */
export function ErrorState({ title = '出错了', description, onRetry, retryLabel = '重试', className }: ErrorStateProps): ReactElement {
return (
<div
className={cn('flex flex-col items-center justify-center gap-1.5 rounded-card border border-line bg-red/10 px-6 py-10 text-center', className)}
role="alert"
>
<div className="text-[12.5px] font-medium text-ink">{title}</div>
{description !== undefined && description !== null && description !== '' && (
<div className="max-w-full whitespace-pre-wrap break-words text-[11.5px] leading-4 text-ink-3">{description}</div>
)}
{onRetry && (
<Button variant="ghost" size="sm" className="mt-2" onClick={onRetry}>
{retryLabel}
</Button>
)}
</div>
)
}
// ---- 空态 ----
export interface EmptyStateProps {
title: ReactNode
/** 怎么产生的提示(如何让它不再为空) */
description?: ReactNode
/** 可选动作(如「新建」「去设置」) */
action?: ReactNode
className?: string
}
/** 区块级空态(DESIGN.md §4 标准模式的组件化) */
export function EmptyState({ title, description, action, className }: EmptyStateProps): ReactElement {
return (
<div className={cn('flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center', className)}>
<div className="text-[13px] font-medium text-ink-2">{title}</div>
{description !== undefined && description !== null && description !== '' && (
<div className="max-w-full break-words text-[11.5px] text-ink-3">{description}</div>
)}
{action && <div className="mt-2">{action}</div>}
</div>
)
}
// ---- 按钮 ----
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** primary=强调实心 / ghost=描边(默认)/ danger=危险描边 */
variant?: 'primary' | 'ghost' | 'danger'
/** sm=辅助 pill(h-7 12px)/ md=标准 pill(13px)(默认) */
size?: 'sm' | 'md'
/** true 时禁用并显示旋转指示器(异步提交防重复点击) */
loading?: boolean
}
export function Button({ variant = 'ghost', size = 'md', loading = false, disabled, className, children, ...rest }: ButtonProps): ReactElement {
/* beautifului pill 体系:rounded-full + 150ms ease-out + 按压回缩 0.96;
* filled 变体带 1px 内高光(类已在宿主 Button.tsx 中生成,插件可直接复用)。 */
const variantCls =
variant === 'primary'
? 'bg-ink text-canvas hover:opacity-90 shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]'
: variant === 'danger'
? 'border border-line-strong text-red hover:bg-red/10'
: 'bg-surface text-ink shadow-btn hover:bg-inset'
const sizeCls = size === 'sm' ? 'h-7 px-2.5 text-[12px]' : 'h-[27px] px-3 text-[12.5px]'
return (
<button
type="button"
disabled={disabled || loading}
className={cn(
'inline-flex items-center justify-center gap-1.5 rounded-full font-medium leading-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:pointer-events-none disabled:opacity-50',
variantCls,
sizeCls,
className
)}
{...rest}
>
{loading && <Spinner size={size === 'sm' ? 10 : 12} />}
{children}
</button>
)
}
// ---- 输入控件 ----
/** 单行输入(设置行/表单通用;与宿主设置页同款) */
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>): ReactElement {
return (
<input
className={cn(
'w-full rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
className
)}
{...rest}
/>
)
}
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>): ReactElement {
return (
<textarea
className={cn(
'w-full resize-y bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] leading-4 text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
className
)}
{...rest}
/>
)
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options: Array<{ value: string; label: string }>
}
/** 下拉选择(原生 select,宿主输入框同款外观) */
export function Select({ options, className, ...rest }: SelectProps): ReactElement {
return (
<select
className={cn(
'w-full rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none disabled:opacity-50',
className
)}
{...rest}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
}
export interface SwitchProps {
/** 受控值;不传则内部自持(非受控) */
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
disabled?: boolean
'aria-label'?: string
}
/** 开关(DESIGN.md §4 规格:h-5 w-9,滑块 size-4,开 accent / 关 line-strong) */
export function Switch({ checked, defaultChecked, onCheckedChange, disabled, 'aria-label': ariaLabel }: SwitchProps): ReactElement {
const [inner, setInner] = useState(defaultChecked ?? false)
const isControlled = checked !== undefined
const value = isControlled ? checked : inner
return (
<button
type="button"
role="switch"
aria-checked={value}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => {
const next = !value
if (!isControlled) setInner(next)
onCheckedChange?.(next)
}}
className={cn(
'relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:pointer-events-none disabled:opacity-50',
value ? 'bg-accent' : 'bg-line-strong'
)}
>
<span
className="absolute top-0.5 size-4 rounded-full bg-white shadow transition-transform"
style={{ transform: `translateX(${value ? 16 : 2}px)` }}
/>
</button>
)
}
// ---- 布局与展示 ----
/** 卡片容器(rounded-card + line 边框;内边距由内容自理) */
export function Card({ className, children, ...rest }: ComponentProps<'div'>): ReactElement {
return (
<div className={cn('rounded-card border border-line bg-surface', className)} {...rest}>
{children}
</div>
)
}
export interface SettingRowProps {
label: ReactNode
/** 次级说明(控件左侧标题下方) */
description?: ReactNode
/** 右侧控件(Switch / Input / Button / Select…) */
children?: ReactNode
className?: string
}
/** 设置行(DESIGN.md §4 标准模式:label 左 / 控件右) */
export function SettingRow({ label, description, children, className }: SettingRowProps): ReactElement {
return (
<div className={cn('flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5', className)}>
<div className="min-w-0">
<div className="text-[12.5px] text-ink">{label}</div>
{description !== undefined && description !== null && description !== '' && (
<div className="mt-0.5 break-words text-[11px] leading-4 text-ink-3">{description}</div>
)}
</div>
{children}
</div>
)
}
export interface BadgeProps {
/** neutral=灰 / accent=强调 / danger=危险 */
tone?: 'neutral' | 'accent' | 'danger'
className?: string
children: ReactNode
}
/** 徽标(版本、来源、状态等元信息) */
export function Badge({ tone = 'neutral', className, children }: BadgeProps): ReactElement {
const toneCls = tone === 'accent' ? 'bg-accent/10 text-accent' : tone === 'danger' ? 'bg-red/10 text-red' : 'bg-hover text-ink-3'
return <span className={cn('rounded-control px-1.5 py-0.5 text-[10px]', toneCls, className)}>{children}</span>
}
+51
View File
@@ -0,0 +1,51 @@
# plugin-template · 插件脚手架
复制本目录到插件位置(改目录名 = 改前先改 manifest.id),改掉 `id`/`name`/`description` 即完成最小定制。
## 目录
```
manifest.json 清单(id/name/version/minContract 必改)
main.mjs 主进程侧:Agent 能力 + 设置声明 + 日志埋点(范例齐备)
src/renderer.tsx 宿主内 React 组件源码(JSX;公共组件库 + 三态示范)
renderer.mjs 预构建产物(开箱即用;改了源码需重新构建)
shims/ 构建期把 react 别名到宿主单例(勿改)
```
## 构建渲染层组件
```sh
esbuild src/renderer.tsx --bundle --format=esm --outfile=renderer.mjs \
--jsx=automatic \
--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js \
--alias:@aso/plugin-sdk=../../packages/plugin-sdk/src/index.ts \
--alias:@aso/plugin-sdk/ui=../../packages/plugin-sdk/src/ui.tsx
```
(esbuild 可用 `pnpm dlx esbuild@<版本>` 或任意 ≥0.17 版本;`--alias` 需 ≥0.17。
两条 sdk alias 指向仓库内源码——本目录复制到别处后,改成 sdk 源码的实际路径即可。)
不需要 UI 时:删除 `entry.renderer` 与 `src/`、`shims/`、`renderer.mjs`,只保留 main.mjs。
## 放置位置(三选一,按优先级)
1. `userData/plugins/<id>/`(应用托管目录)
2. `<工作区>/.pi/extensions/<id>/`(项目级)
3. `~/.pi/agent/extensions/<id>/`(全局)
第三方装载仅开发版开启(`ASO_EXTERNAL_PLUGINS=1` 豁免)。
## 验证
1. `pnpm plugin:check <本目录>` → 0 error(性能与样式机器关卡,见 L7)
2. 重启或开关重载 → 设置 → 插件 → 对应作用域出现插件
3. 日志 `userData/logs/main-*.log` 有 `plugin activated pluginId=my-plugin`
4. 对 Agent 说"ping 一下我的插件" → 触发 `my_plugin_ping`
5. 面板/侧栏出现 React 组件:等待/数据/开关/重载齐全(三态示范,hooks 生效)
## 下一步
- 教程索引:`docs/plugins/README.md`(L0→L7)
- 公共组件库与三态铁律:`docs/plugins/07-ui-components.md`(renderer.tsx 即示范)
- 性能与检验器:`docs/plugins/08-performance.md`
- 视觉规范:`docs/plugins/DESIGN.md`(提发布前过一遍自检清单)
- 契约全集:`docs/plugins.md`
+59
View File
@@ -0,0 +1,59 @@
// 主进程侧:Agent 能力(pi 工具/命令/事件)+ 设置声明 + 内联 UI。
// 日志规范见 docs/plugins/06-self-diagnosis.md:激活时 info 一条能力清单,一切 catch 记 error。
import { definePlugin } from '@aso/plugin-sdk'
export default definePlugin((api) => {
const log = api.log
log.info('activated', { manifest: api.manifest.version })
// ---- 设置(用户可配置;值为宿主持久化) ----
api.ui.registerSettings({
items: [
{ type: 'string', key: 'greeting', label: '问候语', default: 'Hello', placeholder: 'Hello' },
{ type: 'boolean', key: 'verbose', label: '详细日志', default: false }
]
})
let values = api.ui.getSettingValues()
api.ui.onSettingsChange((next) => {
values = next
log.info('settings changed', { greeting: next.greeting })
})
api.ui.onSettingsAction((key) => {
log.info('settings action', { key })
})
// ---- Agent 工具示例 ----
api.pi.registerTool({
name: 'my_plugin_ping',
description: '返回问候语(示例工具:把这段换成真正的能力)',
parameters: {},
async execute() {
log.debug('ping called', { verbose: values.verbose })
return { output: `${values.greeting ?? 'Hello'} from my-plugin` }
}
})
// ---- 状态胶囊示范:顶栏紧凑状态条,点击展开详情弹层(数据驱动,宿主统一渲染) ----
api.ui.setStatusItem('summary', {
text: '示例 1/2',
tone: 'accent',
detail: {
sections: [
{
rows: [
{ label: '示例任务', detail: '把这段换成真正的状态数据', trailing: '进行中', tone: 'accent' },
{ label: '打开面板', openView: { view: api.manifest.id } }
]
}
]
}
})
// ---- 面板/视图组件在 renderer.mjs(JSX 构建见 README);无 UI 需求可整段去掉 ----
log.info('ready', { hasRenderer: true })
return () => {
log.info('disposed')
}
})
+14
View File
@@ -0,0 +1,14 @@
{
"id": "my-plugin",
"name": "我的插件",
"version": "0.1.0",
"minContract": "0.3.0",
"description": "TODO:一句话描述插件做什么",
"order": 50,
"panel": true,
"icon": "Blocks",
"entry": {
"main": "main.mjs",
"renderer": "renderer.mjs"
}
}
+177
View File
@@ -0,0 +1,177 @@
// shims/react.js
var R = window.__ASO_PLUGIN_HOST__.react;
var {
useState,
useEffect,
useRef,
useMemo,
useCallback,
createContext,
useContext,
Fragment,
memo,
forwardRef,
createElement
} = R;
// ../plugin-sdk/src/async.ts
var TimeoutError = class extends Error {
constructor(message) {
super(message);
this.name = "TimeoutError";
}
};
function withTimeout(promise, ms, label) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new TimeoutError(`${label ?? "\u64CD\u4F5C"} \u8D85\u65F6\uFF08${ms}ms\uFF09`));
}, ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
// shims/jsx-runtime.js
var { jsx, jsxs, Fragment: Fragment2 } = window.__ASO_PLUGIN_HOST__.jsxRuntime;
// ../plugin-sdk/src/ui.tsx
function cn(...classes) {
return classes.filter(Boolean).join(" ");
}
function Spinner({ size = 14, className }) {
return /* @__PURE__ */ jsx(
"span",
{
role: "status",
"aria-label": "\u52A0\u8F7D\u4E2D",
className: cn("inline-block shrink-0 animate-spin rounded-full border-line-strong", className),
style: { width: size, height: size, borderWidth: 2, borderTopColor: "var(--accent)" }
}
);
}
function LoadingState({ label = "\u52A0\u8F7D\u4E2D\u2026", className }) {
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center justify-center gap-2 py-10 text-center", className), children: [
/* @__PURE__ */ jsx(Spinner, {}),
/* @__PURE__ */ jsx("div", { className: "text-[11.5px] text-ink-3", children: label })
] });
}
function ErrorState({ title = "\u51FA\u9519\u4E86", description, onRetry, retryLabel = "\u91CD\u8BD5", className }) {
return /* @__PURE__ */ jsxs(
"div",
{
className: cn("flex flex-col items-center justify-center gap-1.5 rounded-card border border-line bg-red/10 px-6 py-10 text-center", className),
role: "alert",
children: [
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] font-medium text-ink", children: title }),
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "max-w-full whitespace-pre-wrap break-words text-[11.5px] leading-4 text-ink-3", children: description }),
onRetry && /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "mt-2", onClick: onRetry, children: retryLabel })
]
}
);
}
function EmptyState({ title, description, action, className }) {
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center", className), children: [
/* @__PURE__ */ jsx("div", { className: "text-[13px] font-medium text-ink-2", children: title }),
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "max-w-full break-words text-[11.5px] text-ink-3", children: description }),
action && /* @__PURE__ */ jsx("div", { className: "mt-2", children: action })
] });
}
function Button({ variant = "ghost", size = "md", loading = false, disabled, className, children, ...rest }) {
const variantCls = variant === "primary" ? "bg-accent text-white hover:opacity-90" : variant === "danger" ? "border border-line-strong text-red hover:bg-red/10" : "border border-line-strong text-ink hover:bg-hover";
const sizeCls = size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-[11.5px]";
return /* @__PURE__ */ jsxs(
"button",
{
type: "button",
disabled: disabled || loading,
className: cn(
"inline-flex items-center justify-center gap-1.5 rounded-control font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
variantCls,
sizeCls,
className
),
...rest,
children: [
loading && /* @__PURE__ */ jsx(Spinner, { size: size === "sm" ? 10 : 12 }),
children
]
}
);
}
function Switch({ checked, defaultChecked, onCheckedChange, disabled, "aria-label": ariaLabel }) {
const [inner, setInner] = useState(defaultChecked ?? false);
const isControlled = checked !== void 0;
const value = isControlled ? checked : inner;
return /* @__PURE__ */ jsx(
"button",
{
type: "button",
role: "switch",
"aria-checked": value,
"aria-label": ariaLabel,
disabled,
onClick: () => {
const next = !value;
if (!isControlled) setInner(next);
onCheckedChange?.(next);
},
className: cn(
"relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:pointer-events-none disabled:opacity-50",
value ? "bg-accent" : "bg-line-strong"
),
children: /* @__PURE__ */ jsx(
"span",
{
className: "absolute top-0.5 size-4 rounded-full bg-white shadow transition-transform",
style: { transform: `translateX(${value ? 16 : 2}px)` }
}
)
}
);
}
function SettingRow({ label, description, children, className }) {
return /* @__PURE__ */ jsxs("div", { className: cn("flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5", className), children: [
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] text-ink", children: label }),
description !== void 0 && description !== null && description !== "" && /* @__PURE__ */ jsx("div", { className: "mt-0.5 break-words text-[11px] leading-4 text-ink-3", children: description })
] }),
children
] });
}
// src/renderer.tsx
function fetchItems() {
return withTimeout(Promise.resolve(["\u6761\u76EE\u4E00", "\u6761\u76EE\u4E8C", "\u6761\u76EE\u4E09"]), 3e3, "\u62C9\u53D6\u6570\u636E");
}
function mount(ctx) {
function Panel() {
const [items, setItems] = useState(null);
const [error, setError] = useState(null);
const [verbose, setVerbose] = useState(false);
const [tick, setTick] = useState(0);
useEffect(() => {
let alive = true;
setItems(null);
setError(null);
fetchItems().then((data) => {
if (alive) setItems(data);
}).catch((err) => {
if (alive) setError(err instanceof Error ? err.message : String(err));
});
return () => {
alive = false;
};
}, [tick]);
return /* @__PURE__ */ jsxs("div", { className: "flex h-full flex-col gap-2 overflow-y-auto p-3", children: [
/* @__PURE__ */ jsx("div", { className: "text-[12.5px] text-ink", children: "\u6211\u7684\u63D2\u4EF6\u9762\u677F" }),
error !== null ? /* @__PURE__ */ jsx(ErrorState, { title: "\u6570\u636E\u52A0\u8F7D\u5931\u8D25", description: error, onRetry: () => setTick((t) => t + 1) }) : items === null ? /* @__PURE__ */ jsx(LoadingState, { label: "\u6B63\u5728\u62C9\u53D6\u6570\u636E\u2026" }) : items.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { title: "\u6682\u65E0\u6570\u636E", description: "\u68C0\u67E5\u6570\u636E\u6E90\u540E\u70B9\u51FB\u91CD\u65B0\u52A0\u8F7D" }) : items.map((it) => /* @__PURE__ */ jsx(SettingRow, { label: it, description: "\u793A\u4F8B\u6761\u76EE" }, it)),
/* @__PURE__ */ jsx(SettingRow, { label: "\u8BE6\u7EC6\u6A21\u5F0F", description: "\u5F00\u542F\u540E main \u4FA7\u8F93\u51FA\u66F4\u8BE6\u7EC6\u7684\u65E5\u5FD7", children: /* @__PURE__ */ jsx(Switch, { checked: verbose, onCheckedChange: setVerbose, "aria-label": "\u8BE6\u7EC6\u6A21\u5F0F" }) }),
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "self-start", onClick: () => setTick((t) => t + 1), children: "\u91CD\u65B0\u52A0\u8F7D" })
] });
}
ctx.registerPanel(Panel);
ctx.registerView(Panel);
}
export {
mount as default
};
@@ -0,0 +1,2 @@
// 构建期别名目标:react/jsx-runtime → 宿主全局单例(esbuild --jsx=automatic)
export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime
+16
View File
@@ -0,0 +1,16 @@
// 构建期别名目标:react → 宿主全局单例(双 React 实例会导致 hooks 失效)
const R = window.__ASO_PLUGIN_HOST__.react
export default R
export const {
useState,
useEffect,
useRef,
useMemo,
useCallback,
createContext,
useContext,
Fragment,
memo,
forwardRef,
createElement
} = R

Some files were not shown because too many files have changed in this diff Show More