Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
557887c1b3 | ||
|
|
573ac5bcde | ||
|
|
2efcd8e346 | ||
|
|
d8c20022d8 | ||
|
|
5cf13f4396 | ||
|
|
06a316b6d1 | ||
|
|
95044c3a15 | ||
|
|
09cc5bf648 | ||
|
|
d0b97f16b2 | ||
|
|
6259a2bba6 | ||
|
|
cf2cc1cb77 | ||
|
|
b1405ee71f | ||
|
|
70930e4952 | ||
|
|
4eb78ebc48 | ||
|
|
b6e6031415 | ||
|
|
6507948f01 | ||
|
|
d8c1097b9d | ||
|
|
038339f2fe | ||
|
|
403b3b3ca1 | ||
|
|
c117385d01 | ||
|
|
a052b6a2c4 | ||
|
|
781a6c8beb | ||
|
|
ad16ed7239 | ||
|
|
1326f9b672 | ||
|
|
335a08454a | ||
|
|
9b451c8c25 | ||
|
|
1cb2b554c0 | ||
|
|
e958784d10 |
+1
-2
@@ -10,6 +10,5 @@ release/
|
||||
preview-shots/
|
||||
*.tsbuildinfo
|
||||
capture-log.txt
|
||||
scripts/
|
||||
preview/
|
||||
"tmp-foundation.css"
|
||||
"tmp-foundation.css"
|
||||
|
||||
@@ -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({
|
||||
main: {
|
||||
// pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持)
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
plugins: [externalizeDepsPlugin(), copyStorageWorker()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载,
|
||||
|
||||
@@ -2,9 +2,10 @@ import { getLogger } from '../logger'
|
||||
const log = getLogger('browser')
|
||||
|
||||
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 type { BrowserImportInfo, BrowserTabInfo, BrowserUiState } from '@shared/types'
|
||||
import type { BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types'
|
||||
import {
|
||||
BrowserCdpTimeoutError,
|
||||
BROWSER_OBSERVE_TIMEOUT_MS,
|
||||
@@ -30,8 +31,10 @@ import {
|
||||
* 渲染层 BrowserSlot 量出占位矩形,经 IPC 同步边界(原生视图天然盖在 DOM 之上)。
|
||||
* - 页面观察/操作全部走 CDP(webContents.debugger):AX tree 快照 + ref 代际机制
|
||||
* + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。
|
||||
* - 登录态隔离在 persist:aso-browser 分区;本地浏览器配置导入由 browser-profile.ts
|
||||
* 直接迁移分区目录数据(Cookies/Local Storage + 密钥移植)。
|
||||
* - 登录态隔离在 persist:aso-browser 分区,随分区持久保存。
|
||||
* - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则
|
||||
* (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json,
|
||||
* 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。
|
||||
*/
|
||||
|
||||
const PARTITION = 'persist:aso-browser'
|
||||
@@ -70,7 +73,7 @@ interface TabRecord {
|
||||
|
||||
interface BrowserSettings {
|
||||
riskAcknowledged: boolean
|
||||
imported?: BrowserImportInfo | null
|
||||
proxy?: BrowserProxySettings
|
||||
}
|
||||
|
||||
export interface BrowserObservation {
|
||||
@@ -111,6 +114,36 @@ function textValue(v: unknown): string {
|
||||
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 {
|
||||
const role = textValue(node.role).toLowerCase()
|
||||
if (role === 'textfield' || role === 'textbox' || role === 'searchbox') return true
|
||||
@@ -289,19 +322,24 @@ export class BrowserController {
|
||||
private closedTabs: Array<{ title: string; url: string; closedAt: number }> = []
|
||||
|
||||
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 标识
|
||||
this.browserSession.setUserAgent(
|
||||
this.browserSession.getUserAgent().replace(/Electron\/[\d.]+/, `InkreachAgent/${app.getVersion()}`)
|
||||
)
|
||||
session.setUserAgent(session.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 base = item.getFilename() || 'download'
|
||||
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) : ''
|
||||
let target = join(dir, base)
|
||||
for (let i = 1; existsSync(target) && i < 100; i++) {
|
||||
@@ -309,7 +347,11 @@ export class BrowserController {
|
||||
}
|
||||
item.setSavePath(target)
|
||||
})
|
||||
this.settings = this.loadSettings()
|
||||
// 启动即应用持久化的代理设置(setProxy 异步;失败不阻塞浏览器初始化,日志可查)
|
||||
this.applyProxyTo(session, this.proxySettings()).catch((err: unknown) => {
|
||||
log.error('启动时代理设置应用失败:', { err })
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
// ---- 设置持久化 ----
|
||||
@@ -321,12 +363,16 @@ export class BrowserController {
|
||||
private loadSettings(): BrowserSettings {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<BrowserSettings>
|
||||
const proxy = raw.proxy
|
||||
return {
|
||||
riskAcknowledged: raw.riskAcknowledged === true,
|
||||
imported: raw.imported ?? null
|
||||
proxy:
|
||||
proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system')
|
||||
? proxy
|
||||
: { mode: 'system' }
|
||||
}
|
||||
} catch {
|
||||
return { riskAcknowledged: false }
|
||||
return { riskAcknowledged: false, proxy: { mode: 'system' } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,20 +384,66 @@ export class BrowserController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 配置导入完成后由 browser-profile.ts 回写摘要 */
|
||||
setImportedInfo(info: BrowserImportInfo | null): void {
|
||||
this.settings.imported = info
|
||||
/** 历史遗留清理:删除已下线的「登录态导入」功能留下的轮换分区目录与暂存
|
||||
* (aso-browser-g* / .import-staging-* / aso-browser.pending-import)。
|
||||
* 控制器创建时无人持有这些目录的句柄;失败只记日志,下次启动再试。 */
|
||||
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.emitState()
|
||||
}
|
||||
|
||||
get importedInfo(): BrowserImportInfo | null {
|
||||
return this.settings.imported ?? null
|
||||
}
|
||||
|
||||
/** 当前是否有活跃标签页(导入配置前必须没有) */
|
||||
get hasTabs(): boolean {
|
||||
return this.tabs.size > 0
|
||||
return next
|
||||
}
|
||||
|
||||
// ---- 窗口与状态广播 ----
|
||||
@@ -395,7 +487,7 @@ export class BrowserController {
|
||||
activeTabId: this.activeTabId,
|
||||
recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })),
|
||||
trace: this.traceText,
|
||||
imported: this.settings.imported ?? null
|
||||
proxy: this.proxySettings()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,16 +548,19 @@ export class BrowserController {
|
||||
if (!this.owner || this.owner.isDestroyed()) throw new Error('主窗口尚未就绪,无法创建浏览器标签。')
|
||||
if (this.tabs.size >= MAX_TABS) throw new Error(`浏览器标签页已达上限(${MAX_TABS}),请先关闭不需要的标签。`)
|
||||
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
// popup 的 viewOptions 不一定带 partition(会导致弹窗落入默认会话),统一强制受管分区
|
||||
const view = new WebContentsView(
|
||||
viewOptions ?? {
|
||||
webPreferences: {
|
||||
partition: PARTITION,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
}
|
||||
}
|
||||
viewOptions
|
||||
? { ...viewOptions, webPreferences: { ...(viewOptions.webPreferences ?? {}), partition: PARTITION } }
|
||||
: {
|
||||
webPreferences: {
|
||||
partition: PARTITION,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
}
|
||||
}
|
||||
)
|
||||
const tab: TabRecord = {
|
||||
tabId,
|
||||
@@ -611,7 +706,7 @@ export class BrowserController {
|
||||
return this.buildState()
|
||||
}
|
||||
|
||||
/** 关闭整个受管浏览器(销毁全部标签;导入配置前也会走这里) */
|
||||
/** 关闭整个受管浏览器(销毁全部标签;分区会话与登录态保留) */
|
||||
close(): BrowserUiState {
|
||||
for (const tabId of [...this.tabs.keys()]) this.closeTab(tabId)
|
||||
this.agentTabId = null
|
||||
|
||||
@@ -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 ? '若登录态未生效,重启应用后重试。' : ''
|
||||
}`
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { userInfo } from 'node:os'
|
||||
import { AgentService } from './pi/agent-service'
|
||||
import { getPluginHost } from './plugins/plugin-host'
|
||||
import { getBrowserController } from './browser/browser-controller'
|
||||
import { detectBrowserSources, importBrowserProfile } from './browser/browser-profile'
|
||||
import { getLogger } from './logger'
|
||||
|
||||
const log = getLogger('ipc')
|
||||
@@ -402,11 +401,15 @@ export function registerIpc(): AgentService {
|
||||
return browserPass(() => getBrowserController().applyLayout({ tabId, visible, revision, bounds: { x, y, width, height } }))
|
||||
})
|
||||
|
||||
// ---- 本地浏览器配置导入 ----
|
||||
handle('browser:detectSources', () => browserPass(() => detectBrowserSources()))
|
||||
handle('browser:importProfile', (_e, sourceId: unknown, profileDir: unknown) => {
|
||||
if (typeof sourceId !== 'string' || typeof profileDir !== 'string') throw new Error('invalid import source')
|
||||
return browserPass(() => importBrowserProfile(sourceId, profileDir))
|
||||
// ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ----
|
||||
handle('browser:getProxy', () => getBrowserController().getProxySettings())
|
||||
handle('browser:setProxy', (_e, input: unknown) => {
|
||||
if (!input || typeof input !== 'object') throw new Error('invalid proxy settings')
|
||||
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 })
|
||||
})
|
||||
|
||||
// ---- 插件系统 ----
|
||||
@@ -470,6 +473,16 @@ export function registerIpc(): AgentService {
|
||||
if (typeof id !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid plugin toggle')
|
||||
return getPluginHost().setEnabled(id, enabled)
|
||||
})
|
||||
// 打开插件私有数据目录(资源管理器中显示;不存在则先建)
|
||||
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')
|
||||
|
||||
@@ -15,8 +15,11 @@ export const browserPlugin: BuiltinPlugin = {
|
||||
minContract: '0.1.0',
|
||||
panel: true,
|
||||
order: 10,
|
||||
description: '受管浏览器'
|
||||
description: '受管浏览器',
|
||||
icon: 'Globe'
|
||||
},
|
||||
/** 宿主专属二级设置页(代理设置;见 SettingsModal 插件页特判) */
|
||||
settingsPage: true,
|
||||
main: (api) => {
|
||||
for (const tool of browserTools) {
|
||||
api.pi.registerTool(tool)
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface BuiltinPlugin {
|
||||
main?: HarnessPluginFactory
|
||||
/** 渲染层宿主树组件 key(renderer/src/plugins/builtin-components.ts) */
|
||||
component?: string
|
||||
/** 带宿主专属二级设置页(设置页插件行可点击进入、可固定一级入口;
|
||||
* 不走声明式设置表单,页面内容由设置页特判渲染) */
|
||||
settingsPage?: true
|
||||
}
|
||||
|
||||
export const BUILTIN_PLUGINS: BuiltinPlugin[] = [browserPlugin]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* 签名校验(在线更新管道)尚未接入:当前外置插件直接装载(TODO: M5 接入
|
||||
* ed25519 验签 + 开发者模式豁免)。
|
||||
*/
|
||||
import { app, BrowserWindow, net, protocol } from 'electron'
|
||||
import { app, BrowserWindow, net, protocol, shell } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -31,19 +31,21 @@ import type {
|
||||
PluginSettingValues,
|
||||
PluginSettingsSchemas,
|
||||
PluginSettingsValuesMap,
|
||||
PluginStorageSchema,
|
||||
StatusItem,
|
||||
ToolbarButtonDef
|
||||
} from '@aso/plugin-sdk'
|
||||
import { withTimeout } from '@aso/plugin-sdk'
|
||||
import type { PluginInfo, PluginStatusEntry, PluginToolbarEntry, PluginsUiState } from '@shared/types'
|
||||
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 { PluginStorageService } from './plugin-storage'
|
||||
import { getLogger } from '../logger'
|
||||
|
||||
const log = getLogger('plugin-host')
|
||||
const pluginLog = getLogger('plugin')
|
||||
|
||||
/** harness 插件契约版本(manifest.minContract 与之比对;破坏性变更时提升) */
|
||||
export const HARNESS_CONTRACT_VERSION = '0.2.0'
|
||||
export const HARNESS_CONTRACT_VERSION = '0.3.0'
|
||||
|
||||
/** 插件激活限时(异步工厂的兜底超时):挂死的插件不得阻塞 runtime 重建主流程 */
|
||||
export const PLUGIN_ACTIVATION_TIMEOUT_MS = 10_000
|
||||
@@ -84,6 +86,34 @@ function compareSemver(a: string, b: string): number {
|
||||
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 {
|
||||
private resolved = new Map<string, ResolvedPlugin>()
|
||||
private disabled = new Set<string>()
|
||||
@@ -102,9 +132,14 @@ class PluginHost {
|
||||
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
|
||||
private statusItems = new Map<string, Map<string, StatusItem>>()
|
||||
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
|
||||
/** 插件 KV 存储(pluginId → key → value) */
|
||||
private storage = new Map<string, Map<string, unknown>>()
|
||||
private storageSaveTimer: NodeJS.Timeout | null = null
|
||||
/** 状态条目最近写入时间(悬浮胶囊"最新消息"判定),key = pluginId\0itemId */
|
||||
private statusUpdatedAt = new Map<string, number>()
|
||||
/** 跨插件服务注册表(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 {
|
||||
return join(app.getPath('userData'), 'plugins')
|
||||
@@ -114,10 +149,6 @@ class PluginHost {
|
||||
return join(app.getPath('userData'), 'aso-plugin-settings.json')
|
||||
}
|
||||
|
||||
private get storageFile(): string {
|
||||
return join(app.getPath('userData'), 'aso-plugin-storage.json')
|
||||
}
|
||||
|
||||
private get valuesFile(): string {
|
||||
return join(app.getPath('userData'), 'aso-plugin-values.json')
|
||||
}
|
||||
@@ -130,6 +161,7 @@ class PluginHost {
|
||||
} catch {
|
||||
// 已存在或创建失败都继续(扫描时再报)
|
||||
}
|
||||
this.storageService = new PluginStorageService(join(app.getPath('userData'), 'plugin-data'))
|
||||
log.info('plugin host init', {
|
||||
externalRoot: this.externalRoot,
|
||||
externalLoadingEnabled: EXTERNAL_PLUGINS_ENABLED
|
||||
@@ -152,16 +184,6 @@ class PluginHost {
|
||||
} catch {
|
||||
// 无值文件 = 空
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(this.storageFile, 'utf8')) as Record<string, unknown>
|
||||
for (const [pid, kv] of Object.entries(raw)) {
|
||||
if (kv && typeof kv === 'object' && !Array.isArray(kv)) {
|
||||
this.storage.set(pid, new Map(Object.entries(kv as Record<string, unknown>)))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 无存储文件 = 空
|
||||
}
|
||||
void this.scan()
|
||||
}
|
||||
|
||||
@@ -281,6 +303,8 @@ class PluginHost {
|
||||
}
|
||||
: undefined,
|
||||
replacesNative: raw.replacesNative === true,
|
||||
dependencies: sanitizeDeps(raw.dependencies),
|
||||
optionalDependencies: sanitizeDeps(raw.optionalDependencies),
|
||||
updateURL: typeof raw.updateURL === 'string' ? raw.updateURL : undefined
|
||||
}
|
||||
} catch {
|
||||
@@ -312,13 +336,17 @@ class PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新扫描 + 重新导入 + 清 UI 状态 + 重建 runtime(= pi /reload 等效) */
|
||||
/** 重新扫描 + 重新导入 + 清 UI/服务/存储声明 + 重建 runtime(= pi /reload 等效) */
|
||||
async reload(): Promise<void> {
|
||||
log.info('reload begin')
|
||||
this.statusItems.clear()
|
||||
this.toolbarButtons.clear()
|
||||
this.statusUpdatedAt.clear()
|
||||
this.services.clear()
|
||||
this.storageSchemas.clear()
|
||||
await this.scan()
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
this.send('plugins:listChanged', null)
|
||||
if (this.rebuildRuntime) await this.rebuildRuntime()
|
||||
log.info('reload done')
|
||||
}
|
||||
@@ -334,7 +362,8 @@ class PluginHost {
|
||||
/** 设置入口是否固定到设置页一级导航(仅声明了设置表单的插件可开) */
|
||||
async setSettingsNav(id: string, on: boolean): Promise<void> {
|
||||
const plugin = this.resolved.get(id)
|
||||
if (!plugin || !this.settingsSchemas.has(id)) return
|
||||
// 可固定入口 = 声明了设置表单,或内置插件带宿主专属设置页(如浏览器)
|
||||
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)
|
||||
@@ -439,6 +468,11 @@ class PluginHost {
|
||||
return !this.disabled.has(id)
|
||||
}
|
||||
|
||||
/** 内置插件是否带宿主专属二级设置页(不走声明式表单,设置页特判渲染) */
|
||||
private builtinSettingsPage(id: string): boolean {
|
||||
return BUILTIN_PLUGINS.some((b) => b.manifest.id === id && b.settingsPage === true)
|
||||
}
|
||||
|
||||
list(): PluginInfo[] {
|
||||
return [...this.resolved.values()]
|
||||
.map((p) => ({
|
||||
@@ -450,13 +484,39 @@ class PluginHost {
|
||||
hasPage: Boolean(p.source === 'external' && p.manifest.entry?.page),
|
||||
hasComponent: Boolean(p.componentKey),
|
||||
hasRenderer: Boolean(p.source === 'external' && p.manifest.entry?.renderer),
|
||||
hasSettings: (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
||||
settingsNav: this.settingsNavSet.has(p.manifest.id) && (this.settingsSchemas.get(p.manifest.id)?.items.length ?? 0) > 0,
|
||||
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
|
||||
}))
|
||||
.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 {
|
||||
const p = this.resolved.get(id)
|
||||
return p?.componentKey
|
||||
@@ -488,7 +548,14 @@ class PluginHost {
|
||||
uiState(): PluginsUiState {
|
||||
const status: PluginStatusEntry[] = []
|
||||
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[] = []
|
||||
for (const [pluginId, buttons] of this.toolbarButtons) {
|
||||
@@ -506,8 +573,14 @@ class PluginHost {
|
||||
items = new Map()
|
||||
this.statusItems.set(pluginId, items)
|
||||
}
|
||||
if (item) items.set(itemId, item)
|
||||
else items.delete(itemId)
|
||||
const stampKey = `${pluginId}\0${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)
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
}
|
||||
@@ -531,14 +604,20 @@ class PluginHost {
|
||||
this.send('plugins:uiState', this.uiState())
|
||||
}
|
||||
|
||||
/** 清空某插件的全部内联 UI(禁用/重载路径);设置 schema 一并撤下(导航项随之消失) */
|
||||
/** 清空某插件的内联 UI/服务注册/存储声明(禁用/重载/激活失败路径);设置 schema 一并撤下(导航项随之消失) */
|
||||
private clearPluginUi(pluginId: string): void {
|
||||
this.statusItems.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:settingsSchemas', this.settingsSchemasSnapshot())
|
||||
this.send('plugins:listChanged', null)
|
||||
}
|
||||
|
||||
// ---- 深链 ----
|
||||
@@ -547,87 +626,168 @@ class PluginHost {
|
||||
this.send('plugins:view', { view, params })
|
||||
}
|
||||
|
||||
// ---- 插件 KV 存储 ----
|
||||
// ---- 插件数据目录(设置页「打开数据目录 / 清除数据」) ----
|
||||
|
||||
storageFor(pluginId: string) {
|
||||
return {
|
||||
get: <T>(key: string): T | undefined => {
|
||||
const kv = this.storage.get(pluginId)
|
||||
const v = kv?.get(key)
|
||||
return v === undefined ? undefined : (structuredClone(v) as T)
|
||||
},
|
||||
set: (key: string, value: unknown): void => {
|
||||
let kv = this.storage.get(pluginId)
|
||||
if (!kv) {
|
||||
kv = new Map()
|
||||
this.storage.set(pluginId, kv)
|
||||
}
|
||||
kv.set(key, structuredClone(value))
|
||||
this.scheduleStorageSave()
|
||||
},
|
||||
delete: (key: string): void => {
|
||||
this.storage.get(pluginId)?.delete(key)
|
||||
this.scheduleStorageSave()
|
||||
/** 插件数据目录路径(插件不存在返回 null) */
|
||||
pluginDataDir(id: string): string | null {
|
||||
if (!this.resolved.has(id) || !this.storageService) return null
|
||||
return this.storageService.dataDir(id)
|
||||
}
|
||||
|
||||
/** 打开插件数据目录(资源管理器中显示;目录不存在则先建) */
|
||||
openPluginDataDir(id: string): boolean {
|
||||
const dir = this.pluginDataDir(id)
|
||||
if (!dir) return false
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
} catch {
|
||||
// 已存在或创建失败都继续(showItemInFolder 对存在路径展示)
|
||||
}
|
||||
shell.showItemInFolder(dir)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 清除插件全部存储数据(连接关闭 + 删数据目录;重装后插件从零开始) */
|
||||
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 {
|
||||
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) {
|
||||
log.error('save storage failed', { err })
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ---- 聚合 pi extension ----
|
||||
|
||||
/** 挂进 AgentService 的 extensionFactories;工厂内激活全部已启用插件 */
|
||||
/** 挂进 AgentService 的 extensionFactories;拓扑波次激活全部已启用插件 */
|
||||
createAggregatedExtension(): InlineExtension {
|
||||
return {
|
||||
name: 'aso-plugins',
|
||||
factory: (pi: ExtensionAPI) => {
|
||||
const disposers: Array<() => void> = []
|
||||
for (const plugin of this.resolved.values()) {
|
||||
if (!this.isEnabled(plugin.manifest.id) || plugin.error) continue
|
||||
const factory = plugin.builtinFactory ?? plugin.externalFactory
|
||||
if (!factory) continue
|
||||
this.resetSettingsCallbacks(plugin.manifest.id)
|
||||
try {
|
||||
const result = factory(this.buildApi(plugin, pi))
|
||||
const pluginId = plugin.manifest.id
|
||||
if (typeof result === 'function') {
|
||||
disposers.push(result)
|
||||
log.info('plugin activated', { pluginId, source: plugin.source })
|
||||
} else if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||
// 异步工厂:限时兜底(挂死不拖垮 runtime 重建),reject 必须接住(不得成为 unhandled rejection)
|
||||
void withTimeout(result as Promise<void | (() => void)>, PLUGIN_ACTIVATION_TIMEOUT_MS, `插件 ${pluginId} 激活`)
|
||||
.then((d) => {
|
||||
if (typeof d === 'function') disposers.push(d)
|
||||
log.info('plugin activated', { pluginId, source: plugin.source, async: true })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
log.error('plugin activation failed', { pluginId, err })
|
||||
this.clearPluginUi(pluginId)
|
||||
this.send('plugins:listChanged', null)
|
||||
})
|
||||
} else {
|
||||
log.info('plugin activated', { pluginId, source: plugin.source })
|
||||
}
|
||||
} catch (err) {
|
||||
plugin.error = `激活失败: ${err instanceof Error ? err.message : String(err)}`
|
||||
log.error('plugin activation failed', { pluginId: plugin.manifest.id, err })
|
||||
this.clearPluginUi(plugin.manifest.id)
|
||||
this.send('plugins:listChanged', null)
|
||||
}
|
||||
}
|
||||
this.services.clear()
|
||||
/** 本轮激活成功集合(波次推进与依赖方前置判定的依据) */
|
||||
const active = new Set<string>()
|
||||
pi.on('session_shutdown', () => {
|
||||
for (const dispose of disposers) {
|
||||
try {
|
||||
@@ -638,6 +798,31 @@ class PluginHost {
|
||||
}
|
||||
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)))
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,10 +845,39 @@ class PluginHost {
|
||||
}
|
||||
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 {
|
||||
pi,
|
||||
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 }),
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
@@ -3,8 +3,7 @@ import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
AttachmentInfo,
|
||||
BrowserImportResult,
|
||||
BrowserSource,
|
||||
BrowserProxySettings,
|
||||
BrowserUiEvent,
|
||||
BrowserUiState,
|
||||
ChatResources,
|
||||
@@ -126,9 +125,9 @@ const api = {
|
||||
revision: number
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
}): Promise<void> => ipcRenderer.invoke('browser:layout', layout),
|
||||
browserDetectSources: (): Promise<BrowserSource[]> => ipcRenderer.invoke('browser:detectSources'),
|
||||
browserImportProfile: (sourceId: string, profileDir: string): Promise<BrowserImportResult> =>
|
||||
ipcRenderer.invoke('browser:importProfile', sourceId, profileDir),
|
||||
browserGetProxy: (): Promise<BrowserProxySettings> => ipcRenderer.invoke('browser:getProxy'),
|
||||
browserSetProxy: (input: BrowserProxySettings): Promise<BrowserProxySettings> =>
|
||||
ipcRenderer.invoke('browser:setProxy', input),
|
||||
onBrowserEvent: (callback: (event: BrowserUiEvent) => void): (() => void) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, event: BrowserUiEvent): void => callback(event)
|
||||
ipcRenderer.on('browser:event', listener)
|
||||
@@ -170,6 +169,8 @@ const api = {
|
||||
},
|
||||
pluginsSetEnabled: (id: string, enabled: boolean): Promise<void> =>
|
||||
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'),
|
||||
pluginsOpenView: (view: string, params?: unknown): Promise<void> =>
|
||||
ipcRenderer.invoke('plugins:openView', view, params),
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from './plugins/renderer-host'
|
||||
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
|
||||
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
|
||||
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
|
||||
import { PluginFloatingCapsule } from './plugins/PluginFloatingCapsule'
|
||||
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
|
||||
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
|
||||
import { applyThemeColor, watchColorScheme } from './lib/theme'
|
||||
@@ -329,6 +329,32 @@ export default function App(): JSX.Element {
|
||||
// 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失)
|
||||
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 页)
|
||||
const panelTabs = sideTabIds.flatMap((id) => {
|
||||
if (id === 'browser') {
|
||||
@@ -351,8 +377,6 @@ export default function App(): JSX.Element {
|
||||
onCloseTab={browser.closeTab}
|
||||
onCloseAllTabs={browser.closeBrowser}
|
||||
onAcknowledgeRisk={browser.acknowledgeRisk}
|
||||
onDetectSources={browser.detectSources}
|
||||
onImportProfile={browser.importProfile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -424,8 +448,11 @@ export default function App(): JSX.Element {
|
||||
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
|
||||
panelOpen={sideOpen}
|
||||
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">
|
||||
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
|
||||
<div
|
||||
|
||||
@@ -2,11 +2,13 @@ import { memo, useEffect, useMemo, useRef, useState, useTransition, type ReactNo
|
||||
import type { JSX, RefObject } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
LuArrowLeft,
|
||||
LuCheck,
|
||||
LuChevronRight,
|
||||
LuCpu,
|
||||
LuDatabase,
|
||||
LuFolderOpen,
|
||||
LuKeyRound,
|
||||
LuLink2,
|
||||
LuLogOut,
|
||||
LuPlus,
|
||||
LuPuzzle,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
LuSparkles,
|
||||
LuTerminal,
|
||||
LuTextQuote,
|
||||
LuTrash2,
|
||||
LuX
|
||||
} from 'react-icons/lu'
|
||||
import type { IconType } from 'react-icons'
|
||||
@@ -23,6 +26,7 @@ import type {
|
||||
CustomProviderConfig,
|
||||
ModelCatalog,
|
||||
ModelMeta,
|
||||
PluginDepStatus,
|
||||
PluginInfo,
|
||||
ProviderCatalogEntry,
|
||||
ResourceDetails,
|
||||
@@ -33,6 +37,7 @@ import type { PluginSettingsSchema, PluginSettingsSchemas, PluginSettingValues,
|
||||
import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme'
|
||||
import { usePluginList } from '../plugins/store'
|
||||
import { PluginIconValue } from '../plugins/plugin-icons'
|
||||
import { BrowserProxyForm } from './browser/BrowserProxyForm'
|
||||
|
||||
type PluginSettingsTab = `plugin:${string}`
|
||||
type Tab = 'general' | 'model' | 'prompt' | 'plugins' | 'skills' | 'commands' | PluginSettingsTab
|
||||
@@ -631,6 +636,124 @@ function ModelSettings({
|
||||
)
|
||||
}
|
||||
|
||||
/** 依赖状态展示元数据(状态色对齐状态胶囊语义:绿=可用 / 橙=待处理 / 红=失败) */
|
||||
const DEP_STATUS_META: Record<PluginDepStatus['status'], { label: string; cls: string }> = {
|
||||
ok: { label: '可用', cls: 'text-green' },
|
||||
missing: { label: '未安装', cls: 'text-orange' },
|
||||
disabled: { label: '已停用', cls: 'text-orange' },
|
||||
version: { label: '版本不符', cls: 'text-orange' },
|
||||
error: { label: '装载失败', cls: 'text-red' }
|
||||
}
|
||||
|
||||
/** 依赖关系 Tooltip(管理页行内悬停):展示每个依赖的 id / 版本范围 / 必需性 / 实时状态。
|
||||
* 必需依赖缺失或停用时本插件不可用——错误详情见行内红字,去启用在对应插件的行上操作。 */
|
||||
function PluginDepsTip({ deps }: { deps: PluginDepStatus[] }): ReactNode {
|
||||
return (
|
||||
<span className="group relative inline-flex" onClick={(e) => e.stopPropagation()}>
|
||||
<LuLink2 size={11} className="cursor-help text-ink-3 transition-colors group-hover:text-ink-2" />
|
||||
<span className="pointer-events-none invisible absolute left-1/2 top-full z-50 mt-1.5 flex -translate-x-1/2 flex-col gap-1 rounded-card border border-line bg-surface px-2.5 py-2 opacity-0 shadow-overlay transition-opacity duration-150 group-hover:visible group-hover:opacity-100">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-ink-3">依赖</span>
|
||||
{deps.map((d) => (
|
||||
<span key={`${d.id}:${d.required}`} className="flex items-center gap-1.5 whitespace-nowrap text-[11px] text-ink">
|
||||
<span className="font-mono">{d.id}</span>
|
||||
<span className="font-mono text-ink-3">{d.range}</span>
|
||||
<span className="ml-auto pl-3 text-ink-3">{d.required ? '必需' : '可选'}</span>
|
||||
<span className={`w-14 text-right ${DEP_STATUS_META[d.status].cls}`}>{DEP_STATUS_META[d.status].label}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 插件存储区(插件二级页):机器可读 schema 展示 + 数据目录/清除数据动作。
|
||||
* 数据在 userData/plugin-data/<id>/data.db(每插件一库,宿主共享 worker 持有连接)。 */
|
||||
function PluginStorageSection({
|
||||
info,
|
||||
onOpenDir,
|
||||
onClearData
|
||||
}: {
|
||||
info: PluginInfo
|
||||
onOpenDir: () => void
|
||||
onClearData: () => void
|
||||
}): ReactNode {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const schema = info.storageSchema
|
||||
return (
|
||||
<div className="rounded-card border border-line">
|
||||
<div className="flex items-center gap-2 border-b border-line px-3 py-2">
|
||||
<LuDatabase size={13} className="shrink-0 text-ink-3" />
|
||||
<span className="text-[12px] font-medium text-ink">存储区</span>
|
||||
<span className="min-w-0 truncate text-[10.5px] text-ink-3" title={`userData/plugin-data/${info.manifest.id}`}>
|
||||
SQLite · 插件私有
|
||||
</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDir}
|
||||
className="flex items-center gap-1 rounded-control border border-line-strong px-2 py-1 text-[11px] text-ink transition-colors hover:bg-hover"
|
||||
>
|
||||
<LuFolderOpen size={11} />
|
||||
数据目录
|
||||
</button>
|
||||
{confirming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirming(false)
|
||||
onClearData()
|
||||
}}
|
||||
onMouseLeave={() => setConfirming(false)}
|
||||
className="flex items-center gap-1 rounded-control bg-red px-2 py-1 text-[11px] font-medium text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
<LuTrash2 size={11} />
|
||||
再点一次确认
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="flex items-center gap-1 rounded-control border border-line-strong px-2 py-1 text-[11px] text-ink-3 transition-colors hover:bg-hover hover:text-red"
|
||||
>
|
||||
<LuTrash2 size={11} />
|
||||
清除数据
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{schema && (
|
||||
<div className="flex flex-col gap-2.5 px-3 py-2.5">
|
||||
{schema.kv && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 rounded-[4px] bg-hover px-1.5 py-0.5 font-mono text-[10.5px] text-ink-2">kv</span>
|
||||
<span className="min-w-0 text-[11px] leading-4 text-ink-3">{schema.kv.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{schema.tables.map((t) => (
|
||||
<div key={t.name}>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 rounded-[4px] bg-hover px-1.5 py-0.5 font-mono text-[10.5px] text-ink-2">{t.name}</span>
|
||||
<span className="min-w-0 text-[11px] leading-4 text-ink-3">{t.description}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col gap-0.5 pl-2">
|
||||
{t.columns.map((c) => (
|
||||
<div key={c.name} className="flex items-baseline gap-2 text-[11px] leading-4">
|
||||
<span className="w-28 shrink-0 truncate font-mono text-ink-2" title={c.name}>
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="w-16 shrink-0 font-mono text-ink-3">{c.type}</span>
|
||||
<span className="min-w-0 text-ink-3">{c.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 插件页:内置 / 全局 / 项目三个作用域统一管理(交互对齐技能页),行内启停开关;单一事实源在主进程,listChanged 驱动刷新 */
|
||||
function PluginsPanel({
|
||||
togglingId,
|
||||
@@ -652,7 +775,7 @@ function PluginsPanel({
|
||||
onNavToggle: (pluginId: string, on: boolean) => void
|
||||
}): ReactNode {
|
||||
const plugins = usePluginList()
|
||||
const [scope, setScope] = useState<'user' | 'project' | 'builtin'>('user')
|
||||
const [scope, setScope] = useState<'all' | 'user' | 'project' | 'builtin'>('all')
|
||||
/** 全局插件重载进行中(重扫目录 + 重建运行时;期间刷新按钮禁用旋转) */
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [dirs, setDirs] = useState<{ user: string[]; project: string[]; global: string[] }>({
|
||||
@@ -674,17 +797,23 @@ function PluginsPanel({
|
||||
}, [])
|
||||
|
||||
const scoped =
|
||||
scope === 'builtin'
|
||||
? plugins.filter((p) => p.source === 'builtin')
|
||||
: plugins.filter((p) => p.source === 'external' && (scope === 'project' ? p.origin === 'project' : p.origin !== 'project'))
|
||||
scope === 'all'
|
||||
? plugins
|
||||
: scope === 'builtin'
|
||||
? plugins.filter((p) => p.source === 'builtin')
|
||||
: plugins.filter((p) =>
|
||||
p.source === 'external' ? (scope === 'project' ? p.origin === 'project' : p.origin !== 'project') : false
|
||||
)
|
||||
const scopedDirs = scope === 'user' ? dirs.global : scope === 'project' ? dirs.project : []
|
||||
const scopedHints = scope === 'user' ? ['全局'] : ['项目']
|
||||
const originLabel = (p: PluginInfo): string =>
|
||||
p.source === 'builtin' ? '内置' : p.origin === 'project' ? '项目' : '全局'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<div className="flex items-center rounded-control bg-zinc-100 p-0.5">
|
||||
{(['user', 'project', 'builtin'] as const).map((s) => (
|
||||
{(['all', 'user', 'project', 'builtin'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
@@ -693,7 +822,7 @@ function PluginsPanel({
|
||||
scope === s ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{s === 'user' ? '全局' : s === 'project' ? '项目' : '内置'}
|
||||
{s === 'all' ? '全部' : s === 'user' ? '全局' : s === 'project' ? '项目' : '内置'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -715,9 +844,15 @@ function PluginsPanel({
|
||||
{scoped.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
|
||||
<div className="text-[13px] font-medium text-ink-2">
|
||||
{scope === 'builtin' ? '暂无内置插件' : devBuild ? '暂无第三方插件' : '第三方插件装载已关闭(用户版构建)'}
|
||||
{scope === 'all'
|
||||
? '暂无插件'
|
||||
: scope === 'builtin'
|
||||
? '暂无内置插件'
|
||||
: devBuild
|
||||
? '暂无第三方插件'
|
||||
: '第三方插件装载已关闭(用户版构建)'}
|
||||
</div>
|
||||
{scope !== 'builtin' && devBuild && (
|
||||
{(scope === 'user' || scope === 'project') && devBuild && (
|
||||
<div className="text-[11.5px] text-ink-3">
|
||||
把含 manifest.json 的插件目录放入
|
||||
{scope === 'user' ? '全局(~/.pi/agent/extensions)' : '项目 .pi/extensions/'}即可自动识别
|
||||
@@ -733,7 +868,8 @@ function PluginsPanel({
|
||||
(a.manifest.order ?? 100) - (b.manifest.order ?? 100) || a.manifest.id.localeCompare(b.manifest.id)
|
||||
)
|
||||
.map((p) => {
|
||||
const openSettings = p.hasSettings && p.enabled
|
||||
const openSettings =
|
||||
p.enabled && (p.hasSettings || Boolean(p.storageSchema) || p.manifest.id === 'browser')
|
||||
return (
|
||||
<div
|
||||
key={p.manifest.id}
|
||||
@@ -758,8 +894,9 @@ function PluginsPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12.5px] font-medium text-ink">{p.manifest.name}</span>
|
||||
<span className="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">
|
||||
{scope === 'builtin' ? '内置' : scope === 'user' ? '全局' : '项目'} v{p.manifest.version}
|
||||
{originLabel(p)} v{p.manifest.version}
|
||||
</span>
|
||||
{p.dependencies.length > 0 && <PluginDepsTip deps={p.dependencies} />}
|
||||
</div>
|
||||
{p.manifest.description && (
|
||||
<div className="mt-1 text-[12px] leading-5 text-ink-3">{p.manifest.description}</div>
|
||||
@@ -1419,20 +1556,46 @@ export function SettingsModal({
|
||||
{/* 右侧:内容页 */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-start gap-3 px-6 pb-3 pt-5">
|
||||
{tab.startsWith('plugin:') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('plugins')}
|
||||
className="mt-0.5 flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 text-[11.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<LuArrowLeft size={13} />
|
||||
返回
|
||||
</button>
|
||||
{tab.startsWith('plugin:') ? (
|
||||
// 二级页顶部:面包屑(导航层,shadcn 语义——上级可点、ChevronRight 分隔、当前页仅标记)
|
||||
// + 页标题(与一级页同级 16px semibold)+ 元数据行(来源 · 版本)
|
||||
(() => {
|
||||
const info = plugins.find((p) => `plugin:${p.manifest.id}` === tab)
|
||||
const originLabel =
|
||||
!info || info.source === 'builtin' ? '内置' : info.origin === 'project' ? '项目' : '全局'
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<nav aria-label="面包屑" className="flex items-center gap-1 text-[11.5px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('plugins')}
|
||||
className="rounded-control px-1 py-0.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
插件
|
||||
</button>
|
||||
<LuChevronRight size={12} aria-hidden="true" className="shrink-0 text-ink-3" />
|
||||
<span aria-current="page" className="truncate text-ink-2">
|
||||
{info?.manifest.name ?? '插件'}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="mt-1.5 flex min-w-0 items-center gap-2">
|
||||
{info && <PluginIconValue icon={info.manifest.icon} size={16} className="shrink-0 text-ink-2" />}
|
||||
<div className="truncate text-[16px] font-semibold text-ink">{info?.manifest.name ?? '插件'}</div>
|
||||
</div>
|
||||
{info && (
|
||||
<div className="mt-0.5 text-[12px] leading-5 text-ink-3">
|
||||
{originLabel}插件 · v{info.manifest.version}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[16px] font-semibold text-ink">{meta.title}</div>
|
||||
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-ink-3">{meta.desc}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[16px] font-semibold text-ink">{meta.title}</div>
|
||||
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-ink-3">{meta.desc}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -1510,8 +1673,24 @@ export function SettingsModal({
|
||||
{tab.startsWith('plugin:') &&
|
||||
(() => {
|
||||
const pid = tab.slice(7)
|
||||
// 内置浏览器:专属二级页(代理设置),不走向声明式设置表单
|
||||
if (pid === 'browser') {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto pb-2 pr-1">
|
||||
<section className="shrink-0 rounded-card border border-line">
|
||||
<div className="border-b border-line px-3 py-2 text-[12px] font-medium text-ink">代理设置</div>
|
||||
<div className="p-3">
|
||||
<BrowserProxyForm />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const schema = settingSchemas[pid]
|
||||
if (!schema || schema.items.length === 0) {
|
||||
const info = plugins.find((p) => p.manifest.id === pid)
|
||||
const hasForm = Boolean(schema && schema.items.length > 0)
|
||||
const hasStorage = Boolean(info?.storageSchema)
|
||||
if (!hasForm && !hasStorage) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-[12.5px] text-ink-3">
|
||||
该插件未提供配置项(或插件已停用 / 移除)
|
||||
@@ -1519,13 +1698,22 @@ export function SettingsModal({
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||
<PluginSettingsForm
|
||||
schema={schema}
|
||||
values={settingValues[pid] ?? {}}
|
||||
onChange={(key, value) => handleSetSettingValue(pid, key, value)}
|
||||
onAction={(key) => void window.api.pluginsSettingsAction(pid, key)}
|
||||
/>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto pb-2 pr-1">
|
||||
{hasForm && (
|
||||
<PluginSettingsForm
|
||||
schema={schema}
|
||||
values={settingValues[pid] ?? {}}
|
||||
onChange={(key, value) => handleSetSettingValue(pid, key, value)}
|
||||
onAction={(key) => void window.api.pluginsSettingsAction(pid, key)}
|
||||
/>
|
||||
)}
|
||||
{info && hasStorage && (
|
||||
<PluginStorageSection
|
||||
info={info}
|
||||
onOpenDir={() => void window.api.pluginsOpenDataDir(pid).catch(() => undefined)}
|
||||
onClearData={() => run('已清除插件数据', () => window.api.pluginsClearData(pid))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -70,18 +70,26 @@ export function SidePanel({
|
||||
onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta)))
|
||||
}
|
||||
const onUp = (): void => {
|
||||
if (!dragRef.current) return
|
||||
dragRef.current = null
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [onWidthChange])
|
||||
|
||||
const startResize = (e: React.PointerEvent): void => {
|
||||
// preventDefault 必须在 pointerdown 上:否则 Chromium 会把它当作文本选择
|
||||
// 手势的锚点,拖动经过聊天区/面板文字时把沿途内容选中;拖拽期间再对
|
||||
// body 全局禁选(双保险,覆盖手势未被拦截的边缘情况),松手恢复
|
||||
e.preventDefault()
|
||||
dragRef.current = { startX: e.clientX, startWidth: width }
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
// 菜单打开时 Esc 关闭
|
||||
|
||||
@@ -455,8 +455,9 @@ export function SidebarNav({
|
||||
})}
|
||||
</GlideMenu>
|
||||
|
||||
{/* 会话列表区 */}
|
||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
{/* 会话列表区:折叠态 margin 收敛为 1px,让「会话」切换图标与上方
|
||||
导航图标保持同一垂直节奏(行高 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="sidebar-copy absolute inset-0">
|
||||
<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'
|
||||
|
||||
/**
|
||||
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
||||
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
|
||||
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/
|
||||
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
|
||||
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||
* (插件悬浮状态胶囊不再挂这里:改悬浮于 main 区域右上角。)
|
||||
*/
|
||||
export function TitleBar({
|
||||
title,
|
||||
panelOpen,
|
||||
onTogglePanel,
|
||||
extra
|
||||
onTogglePanel
|
||||
}: {
|
||||
title?: string
|
||||
panelOpen: boolean
|
||||
onTogglePanel: () => void
|
||||
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
|
||||
extra?: ReactNode
|
||||
}): JSX.Element {
|
||||
const name = title?.trim()
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
@@ -27,7 +25,6 @@ export function TitleBar({
|
||||
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="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
|
||||
{extra && <div className="ml-3 flex shrink-0 items-center [-webkit-app-region:no-drag]">{extra}</div>}
|
||||
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,20 +4,21 @@ import {
|
||||
LuArrowRight,
|
||||
LuChevronDown,
|
||||
LuCircleAlert,
|
||||
LuDownload,
|
||||
LuGlobe,
|
||||
LuNetwork,
|
||||
LuPlus,
|
||||
LuRotateCw,
|
||||
LuX
|
||||
} from 'react-icons/lu'
|
||||
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
|
||||
import type { BrowserUiState } from '@shared/types'
|
||||
import { BrowserSlot } from './BrowserSlot'
|
||||
import { BrowserProxyForm } from './BrowserProxyForm'
|
||||
|
||||
/**
|
||||
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。
|
||||
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/代理浮层)。
|
||||
* 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView
|
||||
* (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。
|
||||
* 任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。
|
||||
* 任何全屏浮层(设置/搜索/代理)出现时必须卸载 Slot 隐藏原生视图。
|
||||
*/
|
||||
|
||||
interface BrowserPanelProps {
|
||||
@@ -35,14 +36,12 @@ interface BrowserPanelProps {
|
||||
/** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */
|
||||
onCloseAllTabs: () => void
|
||||
onAcknowledgeRisk: () => void
|
||||
onDetectSources: () => Promise<BrowserSource[]>
|
||||
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||
}
|
||||
|
||||
export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||
const { state, suspended } = props
|
||||
const [address, setAddress] = useState('')
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [proxyOpen, setProxyOpen] = useState(false)
|
||||
const [tabListOpen, setTabListOpen] = useState(false)
|
||||
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
|
||||
// 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上)
|
||||
const overlayOpen = importOpen || tabListOpen || tabMenu !== null
|
||||
const overlayOpen = proxyOpen || tabListOpen || tabMenu !== null
|
||||
// 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单)
|
||||
const hideCause = suspended || overlayOpen
|
||||
|
||||
@@ -356,11 +355,12 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportOpen(true)}
|
||||
title="导入本机浏览器登录态"
|
||||
onClick={() => setProxyOpen(true)}
|
||||
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"
|
||||
>
|
||||
<LuDownload size={13} />
|
||||
<LuNetwork size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -384,8 +384,9 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||
)}
|
||||
|
||||
{/* 页面区域:原生 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 ? (
|
||||
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
|
||||
) : activeTab ? (
|
||||
@@ -403,69 +404,25 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{state.trace}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置导入浮层(覆盖面板,出现时原生视图已隐藏) */}
|
||||
{importOpen && (
|
||||
<ImportOverlay
|
||||
state={state}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onDetectSources={props.onDetectSources}
|
||||
onImportProfile={props.onImportProfile}
|
||||
/>
|
||||
)}
|
||||
{/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */}
|
||||
{proxyOpen && <ProxyOverlay onClose={() => setProxyOpen(false)} />}
|
||||
</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 (
|
||||
<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="text-[12.5px] font-medium text-ink-2">导入本机浏览器登录态</div>
|
||||
<div className="text-[12.5px] font-medium text-ink-2">代理设置</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
@@ -475,86 +432,8 @@ function ImportOverlay({
|
||||
<LuX size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 text-[12px] text-ink-2">
|
||||
<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 className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<BrowserProxyForm />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
closeBrowser: () => void
|
||||
acknowledgeRisk: () => void
|
||||
detectSources: () => Promise<BrowserSource[]>
|
||||
importProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||
} {
|
||||
const [state, setState] = useState<BrowserUiState | null>(null)
|
||||
|
||||
@@ -50,8 +48,6 @@ export function useBrowser(): {
|
||||
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
|
||||
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
|
||||
closeBrowser: wrap(() => window.api.browserClose()),
|
||||
acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk()),
|
||||
detectSources: () => window.api.browserDetectSources(),
|
||||
importProfile: (sourceId: string, profileDir: string) => window.api.browserImportProfile(sourceId, profileDir)
|
||||
acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
AttachmentInfo,
|
||||
BrowserImportResult,
|
||||
BrowserSource,
|
||||
BrowserProxySettings,
|
||||
BrowserUiEvent,
|
||||
BrowserUiState,
|
||||
ChatResources,
|
||||
@@ -36,8 +35,6 @@ export type {
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
SessionStatsInfo,
|
||||
BrowserImportResult,
|
||||
BrowserSource,
|
||||
BrowserUiEvent,
|
||||
BrowserUiState
|
||||
}
|
||||
@@ -113,8 +110,8 @@ declare global {
|
||||
revision: number
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
}) => Promise<void>
|
||||
browserDetectSources: () => Promise<BrowserSource[]>
|
||||
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||
browserGetProxy: () => Promise<BrowserProxySettings>
|
||||
browserSetProxy: (input: BrowserProxySettings) => Promise<BrowserProxySettings>
|
||||
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
|
||||
// ---- 插件系统 ----
|
||||
pluginsList: () => Promise<PluginInfo[]>
|
||||
@@ -131,6 +128,8 @@ declare global {
|
||||
onPluginsSettingsSchemas: (callback: (schemas: PluginSettingsSchemas) => void) => () => void
|
||||
onPluginsSettingsValues: (callback: (values: PluginSettingsValuesMap) => void) => () => void
|
||||
pluginsSetEnabled: (id: string, enabled: boolean) => Promise<void>
|
||||
pluginsOpenDataDir: (id: string) => Promise<boolean>
|
||||
pluginsClearData: (id: string) => Promise<void>
|
||||
pluginsReload: () => Promise<void>
|
||||
pluginsOpenView: (view: string, params?: unknown) => Promise<void>
|
||||
pluginsSetStatus: (pluginId: string, itemId: string, item: unknown) => Promise<void>
|
||||
|
||||
@@ -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,185 +0,0 @@
|
||||
/**
|
||||
* 插件状态胶囊(TitleBar B2 槽位):数据驱动渲染主进程广播的状态条目。
|
||||
* 顶栏胶囊只是紧凑状态条;点击行为分三档:
|
||||
* 1. 声明了 detail → 展开宿主渲染的详情面板(ZCode 式:头部标题+关闭、
|
||||
* 分组列表、行尾彩色数值;行内数据再负责深链)
|
||||
* 2. 声明了 openView → 执行深链
|
||||
* 3. 都没有 → 纯展示
|
||||
* 聊天视图与插件视图下均可见。面板点击外部/Esc 关闭。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LuX } from 'react-icons/lu'
|
||||
import type { PluginStatusEntry } from '@shared/types'
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情面板(ZCode Git 工具式):头部标题 + 关闭,分组列表;
|
||||
* 行 = 图标 + 标签(+次级说明)+ 行尾彩色数值。direction 控制从胶囊下方/上方弹出。
|
||||
*/
|
||||
export function StatusPopover({
|
||||
entry,
|
||||
onOpenView,
|
||||
onClose,
|
||||
direction = 'down'
|
||||
}: {
|
||||
entry: PluginStatusEntry
|
||||
onOpenView: (view: string, params?: unknown) => void
|
||||
onClose: () => void
|
||||
direction?: 'down' | 'up'
|
||||
}): ReactNode {
|
||||
const { item } = entry
|
||||
return (
|
||||
<div
|
||||
className={`absolute right-0 z-50 w-80 overflow-hidden rounded-card border border-line bg-surface shadow-overlay ${
|
||||
direction === 'up' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 py-2 pl-3 pr-1.5">
|
||||
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{item.detail?.title ?? item.text}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
className="shrink-0 rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||
>
|
||||
<LuX size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto overscroll-contain border-t border-line px-1.5 py-1.5">
|
||||
{item.detail?.sections.map((section, si) => (
|
||||
<div key={si} className={si > 0 ? 'mt-1' : ''}>
|
||||
{section.title && (
|
||||
<div className="px-2 pb-1 pt-1.5 text-[11px] font-medium text-ink-2">{section.title}</div>
|
||||
)}
|
||||
{section.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-2 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-2">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginStatusCapsules({
|
||||
entries,
|
||||
onOpenView
|
||||
}: {
|
||||
entries: PluginStatusEntry[]
|
||||
onOpenView: (view: string, params?: unknown) => void
|
||||
}): ReactNode {
|
||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 弹层开着时:点外部 / Esc 关闭;条目消失(插件停用/重载)也收起
|
||||
useEffect(() => {
|
||||
if (!openKey) return
|
||||
const onDown = (e: MouseEvent): void => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpenKey(null)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpenKey(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openKey])
|
||||
|
||||
// 条目集合变化(停用/重载/删除)时收起弹层;按 key 序列比较,避免引用变化误关
|
||||
const entryKeys = entries.map((e) => `${e.pluginId}:${e.itemId}`).join(',')
|
||||
useEffect(() => {
|
||||
setOpenKey(null)
|
||||
}, [entryKeys])
|
||||
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div ref={rootRef} className="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 key = `${pluginId}:${itemId}`
|
||||
const expandable = Boolean(item.detail)
|
||||
const clickable = expandable || Boolean(item.openView)
|
||||
const open = openKey === key
|
||||
return (
|
||||
<div key={key} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
title={item.text}
|
||||
onClick={() => {
|
||||
if (item.detail) setOpenKey(open ? null : key)
|
||||
else if (item.openView) onOpenView(item.openView.view, item.openView.params)
|
||||
}}
|
||||
className={`flex max-w-44 shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2 py-0.5 text-[11px] leading-[18px] text-ink-2 transition-colors ${
|
||||
clickable ? 'cursor-pointer hover:border-line-strong hover:text-ink' : 'cursor-default'
|
||||
} ${open ? 'border-line-strong text-ink' : ''}`}
|
||||
>
|
||||
<span aria-hidden className={`size-1.5 shrink-0 rounded-full ${TONE_DOT_CLASS[tone] ?? TONE_DOT_CLASS.default}`} />
|
||||
{item.icon ? <PluginIconValue icon={item.icon} size={12} className="shrink-0" /> : null}
|
||||
<span className="truncate">{item.text}</span>
|
||||
</button>
|
||||
{open && item.detail && (
|
||||
<StatusPopover entry={{ pluginId, itemId, item }} onOpenView={onOpenView} onClose={() => setOpenKey(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -271,16 +271,6 @@ export interface BrowserClosedTab {
|
||||
url: string
|
||||
}
|
||||
|
||||
/** 已导入的本地浏览器配置摘要(面板展示用) */
|
||||
export interface BrowserImportInfo {
|
||||
browser: string
|
||||
profile: string
|
||||
at: string
|
||||
cookies: boolean
|
||||
localStorage: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** 浏览器面板完整状态(事件与查询共用同一结构) */
|
||||
export interface BrowserUiState {
|
||||
/** 是否存在受管浏览器会话(有标签页即视为打开) */
|
||||
@@ -293,36 +283,22 @@ export interface BrowserUiState {
|
||||
recentClosed: BrowserClosedTab[]
|
||||
/** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */
|
||||
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 }
|
||||
|
||||
/** 本地浏览器(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 =
|
||||
| { type: 'session_ready'; state: SessionState }
|
||||
| { type: 'session_error'; message: string }
|
||||
@@ -361,7 +337,18 @@ export type AgentUiEvent =
|
||||
|
||||
// ===== 插件系统(契约类型来自 @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 {
|
||||
@@ -378,10 +365,14 @@ export interface PluginInfo {
|
||||
hasComponent: boolean
|
||||
/** 有渲染层模块(entry.renderer,宿主动态 import 后注册宿主内 React 组件) */
|
||||
hasRenderer: boolean
|
||||
/** 已声明设置表单(api.ui.registerSettings;空 schema 视为无) */
|
||||
/** 有设置页可进:已声明设置表单(api.ui.registerSettings),或内置插件带宿主专属设置页(如浏览器) */
|
||||
hasSettings: boolean
|
||||
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
|
||||
settingsNav: boolean
|
||||
/** 依赖清单及实时状态(manifest.dependencies + optionalDependencies 求值;无声明则空数组) */
|
||||
dependencies: PluginDepStatus[]
|
||||
/** 已声明的存储 schema(api.storage.declareSchema;未声明则缺省) */
|
||||
storageSchema?: PluginStorageSchema
|
||||
/** 装载/激活错误(不影响其他插件) */
|
||||
error?: string
|
||||
}
|
||||
@@ -391,6 +382,8 @@ export interface PluginStatusEntry {
|
||||
pluginId: string
|
||||
itemId: string
|
||||
item: StatusItem
|
||||
/** 最近一次写入时间(宿主记录;悬浮胶囊据此展示"插件最新消息") */
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** 工具栏按钮条目 */
|
||||
|
||||
+22
-7
@@ -1,9 +1,9 @@
|
||||
# 插件系统(harness 插件契约)
|
||||
|
||||
> **教程入口**:[docs/plugins/README.md](./plugins/README.md) —— L0 第一个插件 → L1 工具命令 → L2 设置 → L3 UI 槽位 → L4 React 组件 → L5 自诊断;视觉规范 [DESIGN.md](./plugins/DESIGN.md);脚手架 [packages/plugin-template](../packages/plugin-template/)。本文件是契约全集(参考手册)。
|
||||
> **教程入口**:[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)
|
||||
> 契约版本:`HARNESS_CONTRACT_VERSION = 0.2.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 存储)
|
||||
|
||||
## 两层能力模型
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"panel": true, // 在右侧 SidePanel 出现一个 tab
|
||||
"entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry
|
||||
"replacesNative": false,
|
||||
"dependencies": { "fetch-tools": "^1.0.0" }, // 必需依赖:id → semver 范围(* ^ ~ >= 精确);缺失/停用/版本不符 → 激活失败并提示
|
||||
"optionalDependencies": { "embed-store": "~2.1.0" }, // 可选依赖:缺失只降级(requirePlugin 返回 undefined)
|
||||
"updateURL": "https://…/index.json"
|
||||
}
|
||||
```
|
||||
@@ -53,14 +55,23 @@ export default definePlugin((api) => {
|
||||
api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } })
|
||||
api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页
|
||||
|
||||
// 插件私有 KV(userData/aso-plugin-storage.json,按 pluginId 隔离)
|
||||
api.storage.set('lastQuery', 'xxx')
|
||||
// 插件私有存储(SQLite,userData/plugin-data/<id>/data.db;API 全 async,宿主共享 worker 执行)
|
||||
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 / 禁用 / 重载时调用 */ }
|
||||
})
|
||||
```
|
||||
|
||||
实现: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`)
|
||||
|
||||
@@ -170,11 +181,10 @@ api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮
|
||||
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
|
||||
| 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ |
|
||||
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ |
|
||||
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见;顶栏为紧凑状态条,点击可展开宿主渲染的详情弹层(`detail`:分组/行/徽标/行内深链) | ✅ |
|
||||
| 状态胶囊(B2+B3) | `setStatusItem` 数据驱动,**悬浮胶囊**挂 main 区右上角(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),展开面板头即插件名、`detail` 分组为手风琴(默认收起显示 标题+trailing 徽标,点击展开行)、最大高度随内容自适应、行尾彩色数值与行内深链 | ✅ |
|
||||
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
|
||||
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
|
||||
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
|
||||
| 聊天区浮动胶囊(B3) | 独立浮动态暂缓——顶栏胶囊的详情面板(ZCode 式)已覆盖同场景;数据同源(`setStatusItem`) | ⏳ |
|
||||
|
||||
## 交互基线
|
||||
|
||||
@@ -204,3 +214,8 @@ packages/plugin-template → npm i(workspace 引 @aso/plugin-sdk)
|
||||
页面:vite build --watch → dist/ # 改即重编
|
||||
宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src
|
||||
```
|
||||
|
||||
**重载粒度**:`plugins:reload` 是宿主级动作——重扫全部插件目录 + 重导入 main.mjs +
|
||||
清内联 UI + 重建运行时(所有插件短暂重激活)。没有单插件重载粒度(工具聚合在同一个
|
||||
pi 运行时里,无法热替换单个);改了哪个插件都执行同一个重载。排障走
|
||||
[docs/plugins/09-troubleshooting.md](./plugins/09-troubleshooting.md)。
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
> 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。
|
||||
|
||||
## 1. 状态胶囊(TitleBar 顶部)
|
||||
## 1. 状态胶囊(悬浮胶囊:最小化药丸 ⇄ 展开面板)
|
||||
|
||||
顶栏胶囊是**紧凑状态条**,点击行为三档:
|
||||
所有插件**共用一个悬浮胶囊**,悬浮于 main 区域右上角(侧面板打开时自动左移让位;ZCode 复刻)。插件照常 `setStatusItem`,宿主记录每次写入时间,两种形态:
|
||||
|
||||
- **最小化药丸**:展示「最新的消息」(全部插件中 `updatedAt` 最新的一条:tone 点 + 插件图标 + 文本;多于一条时带总数角标),点击展开面板;
|
||||
- **展开面板**:面板头就是插件名(第一个区域的右侧是收回药丸的 ⤡);多个插件时各占一个区域、区域头可收起/展开(记忆跨启动)。区域内 `detail` 的**分组是手风琴**——默认收起只显示「分组标题 + 行尾徽标」,点击展开 rows(ZCode 式「计划 ›」);无标题分组直接平铺。面板最大高度封顶(min(480px,58vh)),高度随内容自适应,溢出才滚动;点击面板外部 / Esc 收回药丸。
|
||||
|
||||
```js
|
||||
// ① 纯展示
|
||||
// ① 纯展示(只出现在药丸/区域行)
|
||||
api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' })
|
||||
|
||||
// ② 点击深链
|
||||
@@ -18,12 +21,12 @@ api.ui.setStatusItem('conn', {
|
||||
openView: { view: 'my-plugin' } // 点击跳到插件整页视图
|
||||
})
|
||||
|
||||
// ③ 点击展开详情弹层(声明了 detail 时优先于 openView)
|
||||
// ③ 携详情(分组 = 手风琴:收起显示 标题+trailing 徽标,点击展开 rows)
|
||||
api.ui.setStatusItem('tasks', {
|
||||
text: '任务 2/5',
|
||||
tone: 'accent',
|
||||
detail: {
|
||||
title: '任务概览', // 缺省用胶囊文本
|
||||
title: '任务概览', // 面板中省略(区域头已是插件名)
|
||||
sections: [
|
||||
{
|
||||
title: '进行中', // 分组标题可省略
|
||||
|
||||
@@ -74,6 +74,8 @@ export const { jsx, jsxs, Fragment } = window.__ASO_PLUGIN_HOST__.jsxRuntime
|
||||
| 单文件 ≤ 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) 的类名词汇,禁止硬编码色值/阴影。
|
||||
|
||||
@@ -18,6 +18,10 @@ 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 即把插件标记为失败
|
||||
@@ -89,6 +93,11 @@ try {
|
||||
|
||||
## 一张自查表
|
||||
|
||||
- [ ] `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`
|
||||
@@ -97,4 +106,4 @@ try {
|
||||
|
||||
---
|
||||
|
||||
**上一篇** [L6 公共组件库](./07-ui-components.md) · 返回 [教程索引](./README.md)
|
||||
**上一篇** [L6 公共组件库](./07-ui-components.md) · 出问题了?[常见错误速查](./09-troubleshooting.md) · 返回 [教程索引](./README.md)
|
||||
|
||||
@@ -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) 的闭环流程补日志埋点,再重试
|
||||
@@ -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` 却未声明依赖。
|
||||
@@ -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)。
|
||||
@@ -15,6 +15,10 @@
|
||||
| [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 秒总览
|
||||
|
||||
@@ -34,3 +38,4 @@
|
||||
|
||||
- 第三方插件装载默认**仅开发版开启**(打包版关闭,`ASO_EXTERNAL_PLUGINS=1` 可豁免)
|
||||
- 日志文件:`userData/logs/main-YYYY-MM-DD.log`(排查问题的第一入口,见 L5)
|
||||
- 重载是**宿主级**动作(重扫全部插件源 + 重建运行时);改了哪个插件都点同一个「重载」
|
||||
|
||||
+3
-3
@@ -12,8 +12,8 @@
|
||||
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
|
||||
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
|
||||
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
|
||||
| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、状态胶囊详情面板(ZCode Git 工具式:头部+分组+行尾彩色数值,B3 浮动态并入顶栏胶囊)、图标统一 lucide 白名单、面板平铺标签条 |
|
||||
| 未来 | | 插件管理与 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 子进程隔离模式 |
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
**自省**(v0.8.0 → 指导 M4+):
|
||||
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)。
|
||||
4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。
|
||||
5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。
|
||||
|
||||
@@ -6,4 +6,5 @@ export * from './manifest'
|
||||
export * from './settings'
|
||||
export * from './async'
|
||||
export * from './status'
|
||||
export * from './storage'
|
||||
export * from './plugin'
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface PluginManifest {
|
||||
/** 外置插件遮蔽"组件级"内置插件时必须为 true(能力降级声明,
|
||||
* 防止带原生视图能力的内置被页面级外置静默顶掉) */
|
||||
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) */
|
||||
updateURL?: string
|
||||
}
|
||||
|
||||
@@ -6,13 +6,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
||||
import type { PluginManifest } from './manifest'
|
||||
import type { PluginSettingsSchema, PluginSettingValues } from './settings'
|
||||
import type { StatusItem, ToolbarButtonDef } from './status'
|
||||
|
||||
/** 插件私有 KV 存储(主进程落盘 userData/aso-plugin-storage.json,按 pluginId 命名空间隔离) */
|
||||
export interface PluginStorage {
|
||||
get<T>(key: string): T | undefined
|
||||
set(key: string, value: unknown): void
|
||||
delete(key: string): void
|
||||
}
|
||||
import type { PluginStorage } from './storage'
|
||||
|
||||
/** 插件日志通道:宿主注入,写入统一日志文件(userData/logs/,带 pluginId 字段)。
|
||||
* Agent 自诊断依赖该通道——插件应当把装载/激活/关键决策与失败都记到这里。 */
|
||||
@@ -50,10 +44,18 @@ export interface HarnessPluginApi {
|
||||
/** pi 原生扩展 API:工具/事件/命令/消息全走这里(pi.registerTool 等,语义与 pi CLI 扩展一致) */
|
||||
pi: ExtensionAPI
|
||||
ui: HarnessUiApi
|
||||
/** 私有存储:每插件一个 SQLite 库(KV + 自建表 + 文件目录),API 全 async */
|
||||
storage: PluginStorage
|
||||
/** 结构化日志(scope=plugin,自动带 pluginId;error 级别会进日志文件) */
|
||||
log: PluginLogger
|
||||
manifest: PluginManifest
|
||||
/** 注册跨插件服务(按名键控;dispose/重载时宿主自动清空)。
|
||||
* 只有在 manifest 中声明了本插件为依赖的插件才能取用。 */
|
||||
service(name: string, impl: unknown): void
|
||||
/** 取依赖插件的服务对象({ serviceName: impl })。
|
||||
* 未在 dependencies / optionalDependencies 声明 targetId → 抛错(依赖声明即访问授权);
|
||||
* 必需依赖未就绪 → 抛错;可选依赖未就绪 → 返回 undefined。 */
|
||||
requirePlugin(targetId: string): Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
/** 插件工厂:同步或异步返回 dispose */
|
||||
|
||||
@@ -28,10 +28,14 @@ export interface StatusDetailRow {
|
||||
openView?: OpenViewAction
|
||||
}
|
||||
|
||||
/** 详情弹层分组 */
|
||||
/** 详情弹层分组(悬浮面板中为手风琴:默认收起只显示标题行,点击展开 rows) */
|
||||
export interface StatusDetailSection {
|
||||
/** 分组标题(如「Git 工具」「计划」);缺省无标题 */
|
||||
/** 分组标题(如「计划」「进程」);缺省无标题(rows 直接平铺、不可收起) */
|
||||
title?: string
|
||||
/** 标题行尾徽标(如「4/4」「35分7秒」;收起态也可见——ZCode 式摘要) */
|
||||
trailing?: string
|
||||
/** 行尾徽标色调,缺省 default */
|
||||
trailingTone?: StatusTone
|
||||
rows: StatusDetailRow[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
"id": "my-plugin",
|
||||
"name": "我的插件",
|
||||
"version": "0.1.0",
|
||||
"minContract": "0.2.0",
|
||||
"minContract": "0.3.0",
|
||||
"description": "TODO:一句话描述插件做什么",
|
||||
"order": 50,
|
||||
"panel": true,
|
||||
"icon": "Puzzle",
|
||||
"icon": "Blocks",
|
||||
"entry": {
|
||||
"main": "main.mjs",
|
||||
"renderer": "renderer.mjs"
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 插件检验器(plugin:check)——性能与样式标准的机器关卡。
|
||||
*
|
||||
* 用法:
|
||||
* pnpm plugin:check <插件目录> [更多目录...]
|
||||
* node scripts/check-plugin.mjs <插件目录>
|
||||
*
|
||||
* 检查项(error = 必须修,exit 1;warn = 应当修):
|
||||
* 【契约】manifest.json 可解析;id/name/version(semver)/minContract 齐全;
|
||||
* minContract ≤ 宿主契约版本(从 plugin-host.ts 源码提取,单一事实源);
|
||||
* entry.main / entry.renderer / entry.page 指向的文件存在
|
||||
* 【性能】renderer.mjs ≤ 8MB(宿主 readRenderer 上限)、main.mjs ≤ 2MB;
|
||||
* main.mjs 禁同步 IO(*Sync / execSync / spawnSync——阻塞主进程事件循环);
|
||||
* 任何源码禁 alert/confirm/prompt(阻塞渲染进程的同步对话框)
|
||||
* 【单实例】renderer.mjs 必须引用 __ASO_PLUGIN_HOST__(React shim);>300KB 警告
|
||||
* 疑似打包了自带 React 副本
|
||||
* 【样式】禁硬编码 hex 色值(#fff/#ffffff 除外——accent 按钮白字),一切颜色走 token
|
||||
* 【三态】注册了 view/panel 的 renderer 应使用 @aso/plugin-sdk/ui 的
|
||||
* LoadingState/ErrorState/EmptyState/Spinner(等待/失败/空态统一呈现)
|
||||
* 【日志】main.mjs 禁 console.*(统一走 api.log,见 docs/plugins/06-self-diagnosis.md)
|
||||
*
|
||||
* 规则细节见 docs/plugins/08-performance.md;样式规范见 docs/plugins/DESIGN.md。
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..')
|
||||
const MAX_RENDERER_BYTES = 8 * 1024 * 1024
|
||||
const MAX_MAIN_BYTES = 2 * 1024 * 1024
|
||||
const REACT_COPY_SUSPECT_BYTES = 300 * 1024
|
||||
|
||||
/** 从宿主源码提取契约版本(避免检验器里出现第二份常量) */
|
||||
function harnessContractVersion() {
|
||||
const src = join(ROOT, 'apps', 'desktop', 'src', 'main', 'plugins', 'plugin-host.ts')
|
||||
if (!existsSync(src)) return null
|
||||
const m = /HARNESS_CONTRACT_VERSION\s*=\s*'(\d+\.\d+\.\d+)'/.exec(readFileSync(src, 'utf8'))
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
const contract = harnessContractVersion()
|
||||
|
||||
/** lucide 图标白名单:从宿主 plugin-icons.tsx 提取(单一事实源),校验 manifest.icon 用 */
|
||||
function pluginIconNames() {
|
||||
const src = join(ROOT, 'apps', 'desktop', 'src', 'renderer', 'src', 'plugins', 'plugin-icons.tsx')
|
||||
if (!existsSync(src)) return null
|
||||
const names = [...readFileSync(src, 'utf8').matchAll(/^ ([A-Za-z0-9]+): Lu[A-Za-z0-9]+,$/gm)].map((m) => m[1])
|
||||
return names.length ? new Set(names) : null
|
||||
}
|
||||
|
||||
function parseSemver(v) {
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim())
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
|
||||
}
|
||||
|
||||
function compareSemver(a, b) {
|
||||
const pa = parseSemver(a) ?? [0, 0, 0]
|
||||
const pb = parseSemver(b) ?? [0, 0, 0]
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** 递归收集插件目录下的源文件(跳过 node_modules/.git/out) */
|
||||
function collectFiles(dir, base = dir, acc = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
const st = statSync(p)
|
||||
if (st.isDirectory()) {
|
||||
if (name === 'node_modules' || name === '.git' || name === 'out') continue
|
||||
collectFiles(p, base, acc)
|
||||
} else if (/\.(mjs|js|jsx|ts|tsx|html|css)$/.test(name)) {
|
||||
acc.push(relative(base, p).split(sep).join('/'))
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g
|
||||
const HEX_ALLOW = new Set(['#fff', '#ffffff'])
|
||||
const SYNC_IO = /\b(readFileSync|writeFileSync|appendFileSync|copyFileSync|mkdirSync|mkdtempSync|readdirSync|statSync|lstatSync|existsSync|accessSync|truncateSync|chmodSync|chownSync|unlinkSync|rmSync|rmdirSync|renameSync|cpSync|execSync|spawnSync|execFileSync)\s*\(/g
|
||||
const BLOCKING_DIALOG = /\b(alert|confirm|prompt)\s*\(/g
|
||||
const CONSOLE = /\bconsole\.(log|info|warn|error|debug|trace)\s*\(/g
|
||||
const STATE_COMPONENTS = /(LoadingState|ErrorState|EmptyState|Spinner)/
|
||||
const UI_KIT_IMPORT = /@aso\/plugin-sdk\/ui/
|
||||
|
||||
/** 检查单个插件目录,返回 { errors: string[], warns: string[] } */
|
||||
function checkPlugin(dir) {
|
||||
const errors = []
|
||||
const warns = []
|
||||
const err = (msg) => errors.push(msg)
|
||||
const warn = (msg) => warns.push(msg)
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
err(`目录不存在: ${dir}`)
|
||||
return { errors, warns }
|
||||
}
|
||||
|
||||
// ---- 契约 ----
|
||||
const manifestPath = join(dir, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) {
|
||||
err('缺 manifest.json')
|
||||
return { errors, warns }
|
||||
}
|
||||
let manifest
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
} catch (e) {
|
||||
err(`manifest.json 解析失败: ${e.message}`)
|
||||
return { errors, warns }
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(String(manifest.id ?? ''))) err(`manifest.id 非法: ${JSON.stringify(manifest.id)}`)
|
||||
if (!manifest.name) err('缺 manifest.name')
|
||||
if (!parseSemver(manifest.version)) err(`manifest.version 非法 semver: ${JSON.stringify(manifest.version)}`)
|
||||
// 图标规范:统一 lucide(内联 svg 仅旧兼容;未知名宿主回落拼图图标)
|
||||
if (typeof manifest.icon === 'string' && manifest.icon.trim() && !manifest.icon.trim().startsWith('<svg')) {
|
||||
const names = pluginIconNames()
|
||||
if (names) {
|
||||
const pascal = manifest.icon
|
||||
.trim()
|
||||
.replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
if (!names.has(pascal)) {
|
||||
warn(`manifest.icon "${manifest.icon.trim()}" 不在 lucide 白名单(宿主将回落拼图图标);名单见 docs/plugins/DESIGN.md §3`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!manifest.minContract) {
|
||||
warn('缺 manifest.minContract(建议声明,便于契约升级时感知)')
|
||||
} else if (contract && compareSemver(manifest.minContract, contract) > 0) {
|
||||
err(`manifest.minContract ${manifest.minContract} 高于宿主契约 ${contract}(插件比宿主新,无法装载)`)
|
||||
}
|
||||
|
||||
// ---- 依赖声明(dependencies / optionalDependencies:插件 id → semver 范围) ----
|
||||
const DEP_RANGE = /^(\*|(>=|\^|~)?\d+\.\d+\.\d+)$/
|
||||
const ID_OK = /^[a-z][a-z0-9-]*$/
|
||||
const checkDeps = (field, primary) => {
|
||||
const raw = manifest[field]
|
||||
if (raw === undefined) return
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
err(`manifest.${field} 必须是对象(插件 id → semver 范围)`)
|
||||
return
|
||||
}
|
||||
for (const [id, range] of Object.entries(raw)) {
|
||||
if (!ID_OK.test(id)) err(`manifest.${field}.${id}: 插件 id 非法(小写字母开头,字母/数字/连字符)`)
|
||||
if (id === manifest.id) err(`manifest.${field}.${id}: 不能依赖自身`)
|
||||
if (typeof range !== 'string' || !DEP_RANGE.test(range.trim())) {
|
||||
err(`manifest.${field}.${id}: 范围「${JSON.stringify(range)}」非法——支持 * / ^x.y.z / ~x.y.z / >=x.y.z / 精确 x.y.z`)
|
||||
}
|
||||
if (primary && manifest.optionalDependencies && Object.hasOwn(manifest.optionalDependencies, id)) {
|
||||
err(`依赖 ${id} 同时出现在 dependencies 与 optionalDependencies`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(raw).length > 0 && manifest.minContract && contract && compareSemver(manifest.minContract, '0.3.0') < 0) {
|
||||
warn(`声明了 ${field} 但 minContract ${manifest.minContract} < 0.3.0——旧宿主会静默忽略依赖声明,建议提升 minContract`)
|
||||
}
|
||||
}
|
||||
checkDeps('dependencies', true)
|
||||
checkDeps('optionalDependencies', false)
|
||||
|
||||
const entry = manifest.entry ?? {}
|
||||
const mainFile = entry.main ?? 'main.mjs'
|
||||
if (entry.main && !existsSync(join(dir, entry.main))) err(`entry.main 不存在: ${entry.main}`)
|
||||
if (entry.renderer && !existsSync(join(dir, entry.renderer))) {
|
||||
// 源码形态(src/renderer.tsx 等待构建)算 warn;源码也没有才是 error
|
||||
const hasRendererSrc = ['tsx', 'ts', 'jsx', 'js'].some((ext) => existsSync(join(dir, 'src', `renderer.${ext}`)))
|
||||
if (hasRendererSrc) {
|
||||
warn(`entry.renderer 产物缺失(${entry.renderer})——按模板 README 的 esbuild 配方构建后放回`)
|
||||
} else {
|
||||
err(`entry.renderer 不存在: ${entry.renderer}`)
|
||||
}
|
||||
}
|
||||
if (entry.page && !existsSync(join(dir, entry.page))) err(`entry.page 不存在: ${entry.page}`)
|
||||
|
||||
// ---- 尺寸 ----
|
||||
const sizeOf = (f) => {
|
||||
const p = join(dir, f)
|
||||
return existsSync(p) ? statSync(p).size : -1
|
||||
}
|
||||
if (sizeOf(mainFile) > MAX_MAIN_BYTES) err(`${mainFile} 超过 2MB 上限`)
|
||||
const rendererFile = entry.renderer
|
||||
if (rendererFile && sizeOf(rendererFile) > MAX_RENDERER_BYTES) {
|
||||
err(`${rendererFile} 超过 8MB 上限(宿主 readRenderer 拒绝装载)`)
|
||||
}
|
||||
|
||||
// ---- main.mjs:同步 IO + 日志 ----
|
||||
if (!entry.main || existsSync(join(dir, mainFile))) {
|
||||
const mainSrc = existsSync(join(dir, mainFile)) ? readFileSync(join(dir, mainFile), 'utf8') : ''
|
||||
if (mainSrc) {
|
||||
for (const m of mainSrc.matchAll(SYNC_IO)) {
|
||||
err(`${mainFile}: 同步 IO「${m[1]}(」阻塞主进程事件循环——改用 node:fs/promises 的异步版(性能铁律见 08-performance.md)`)
|
||||
}
|
||||
for (const m of mainSrc.matchAll(CONSOLE)) {
|
||||
const level = m[1] === 'log' || m[1] === 'trace' ? 'debug' : m[1]
|
||||
err(`${mainFile}: console.${m[1]}( 绕过统一日志——改用 api.log.${level}(见 L5 自诊断)`)
|
||||
}
|
||||
// 存储契约:用 SQLite 必须声明机器可读 schema(Agent 自我迭代与设置页展示同源)
|
||||
if (/storage\.db\.(exec|run|query|batch)\s*\(/.test(mainSrc) && !/declareSchema\s*\(/.test(mainSrc)) {
|
||||
err(`${mainFile}: 使用了 api.storage.db 但未调用 api.storage.declareSchema——每张表/每个字段的 description 是 Agent 内省与 UI 展示的依据(契约 0.3.0)`)
|
||||
}
|
||||
if (/requirePlugin\s*\(/.test(mainSrc) && !manifest.dependencies && !manifest.optionalDependencies) {
|
||||
err(`${mainFile}: 调用了 api.requirePlugin 但 manifest 未声明 dependencies/optionalDependencies——依赖声明即访问授权,未声明运行时直接抛错`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- renderer:单实例 + 三态 ----
|
||||
if (rendererFile && existsSync(join(dir, rendererFile))) {
|
||||
const src = readFileSync(join(dir, rendererFile), 'utf8')
|
||||
if (!src.includes('__ASO_PLUGIN_HOST__')) {
|
||||
err(`${rendererFile}: 未引用 __ASO_PLUGIN_HOST__——React 必须经宿主 shim 单实例(esbuild --alias:react 配方见 L4),自带副本会导致 hooks 失效`)
|
||||
} else if (statSync(join(dir, rendererFile)).size > REACT_COPY_SUSPECT_BYTES) {
|
||||
warn(`${rendererFile} 超过 300KB:疑似打包了自带 React 副本或未裁剪依赖,请核对 esbuild 配方`)
|
||||
}
|
||||
const registersUi = /register(View|Panel)\s*\(/.test(src)
|
||||
const usesStates = STATE_COMPONENTS.test(src) || UI_KIT_IMPORT.test(src)
|
||||
if (registersUi && !usesStates) {
|
||||
warn(`${rendererFile}: 注册了 view/panel 但未使用公共三态组件(LoadingState/ErrorState/EmptyState)——异步区块请统一等待/失败呈现(见 07-ui-components.md)`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 全源码:硬编码色值 + 阻塞对话框 ----
|
||||
for (const rel of collectFiles(dir)) {
|
||||
const src = readFileSync(join(dir, rel), 'utf8')
|
||||
src.split('\n').forEach((line, idx) => {
|
||||
for (const m of line.matchAll(HEX_COLOR)) {
|
||||
if (HEX_ALLOW.has(m[0].toLowerCase())) continue
|
||||
err(`${rel}:${idx + 1}: 硬编码色值 ${m[0]}——只允许 token(DESIGN.md 铁律 1;#fff 白字除外)`)
|
||||
}
|
||||
for (const m of line.matchAll(BLOCKING_DIALOG)) {
|
||||
warn(`${rel}:${idx + 1}: ${m[1]}( 是阻塞式同步对话框——请用组件库 Button + ErrorState/EmptyState 呈现`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { errors, warns }
|
||||
}
|
||||
|
||||
// ---- CLI ----
|
||||
const args = process.argv.slice(2).filter((a) => a && !a.startsWith('-'))
|
||||
if (args.length === 0) {
|
||||
console.error('用法: pnpm plugin:check <插件目录> [更多目录...]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let totalErr = 0
|
||||
let totalWarn = 0
|
||||
for (const dir of args) {
|
||||
const { errors, warns } = checkPlugin(dir)
|
||||
totalErr += errors.length
|
||||
totalWarn += warns.length
|
||||
console.log(`\n== ${dir}`)
|
||||
for (const e of errors) console.log(` [error] ${e}`)
|
||||
for (const w of warns) console.log(` [warn ] ${w}`)
|
||||
if (errors.length === 0 && warns.length === 0) console.log(' 全部检查通过')
|
||||
}
|
||||
console.log(`\n汇总: ${totalErr} error, ${totalWarn} warn${contract ? `(宿主契约 ${contract})` : ''}`)
|
||||
process.exit(totalErr > 0 ? 1 : 0)
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 拉取 beautifului.dev 的 shadcn registry 源到 docs/ui-ref/beautifului/。
|
||||
* 组件清单来自站点 01-21 节;slug 与锚点基本一致(个别做了别名兜底)。
|
||||
* 用法:node scripts/fetch-beautifului-registry.mjs
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SLUGS = [
|
||||
'loading-state',
|
||||
'thinking',
|
||||
'thinking-state',
|
||||
'streaming-text',
|
||||
'approval-card',
|
||||
'tool-chips',
|
||||
'task-rows',
|
||||
'chat',
|
||||
'chat-composer',
|
||||
'prompt-bar',
|
||||
'recommendation-card',
|
||||
'context-cards',
|
||||
'diff-table',
|
||||
'records-table',
|
||||
'filter-table',
|
||||
'sidebar-nav',
|
||||
'search',
|
||||
'flowchart',
|
||||
'insight-cards',
|
||||
'code-block',
|
||||
'fine-tune-card',
|
||||
'selection-actions',
|
||||
'agent-screen',
|
||||
'foundation',
|
||||
'button',
|
||||
'glide-menu',
|
||||
'entity-chip',
|
||||
'value-pill',
|
||||
'shimmer',
|
||||
'stream-text'
|
||||
]
|
||||
|
||||
const OUT = join(process.cwd(), 'docs', 'ui-ref', 'beautifului')
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
for (const slug of SLUGS) {
|
||||
const url = `https://www.beautifului.dev/r/${slug}.json`
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
console.log(`[miss ] ${slug} (${res.status})`)
|
||||
continue
|
||||
}
|
||||
const json = await res.json()
|
||||
writeFileSync(join(OUT, `${slug}.json`), JSON.stringify(json, null, 2), 'utf8')
|
||||
const files = (json.files ?? []).map((f) => f.path).join(', ')
|
||||
console.log(`[ok ] ${slug} files: ${files || '(none)'}`)
|
||||
} catch (err) {
|
||||
console.log(`[error] ${slug}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user