Author SHA1 Message Date
Rick Ye 6507948f01 fix(sidebar): 折叠态「会话」切换图标与导航图标列等距 2026-09-11 16:44:37 +08:00
Rick Ye d8c1097b9d fix(sidepanel): 拖拽宽度把手时不再选中其他区域文字 2026-09-11 16:41:22 +08:00
Rick Ye 038339f2fe fix(browser): Agent 调用浏览器工具自动展开面板 + 页面区让出拖拽把手 2026-09-11 16:25:25 +08:00
Rick Ye 403b3b3ca1 fix(plugins): 悬浮胶囊药丸宽度收至 88px(文本约两字截断) 2026-09-11 16:15:34 +08:00
Rick Ye c117385d01 fix(plugins): 悬浮胶囊 ⤡ 收起按钮贴面板最右缘(区域头仍收拢左侧) 2026-09-11 16:13:40 +08:00
Rick Ye a052b6a2c4 fix(plugins): 悬浮胶囊细节修正——药丸收窄加高、面板高度自适应、区域头控件收拢 2026-09-11 16:08:56 +08:00
Rick Ye 781a6c8beb fix(plugins): 悬浮胶囊三点修正——位置/面板头/分组手风琴 2026-09-11 16:00:48 +08:00
Rick Ye ad16ed7239 feat(plugins): 悬浮状态胶囊(B3 落地,复刻 ZCode 药丸⇄面板二态) 2026-09-11 15:49:50 +08:00
Rick Ye 1326f9b672 fix(settings): 插件二级页头部补层级——面包屑导航层 + 大标题 + 元数据行 2026-09-11 15:29:24 +08:00
Rick Ye 335a08454a feat(settings): 插件二级页顶部改面包屑导航(shadcn Breadcrumb 语义) 2026-09-11 15:17:21 +08:00
Rick Ye 9b451c8c25 docs(plugins): 依赖与存储文档(L8/L9)+ 检验器入库并升级契约 0.3.0 规则 2026-09-11 15:00:59 +08:00
Rick Ye 1cb2b554c0 feat(plugins): 依赖互调 + SQLite 存储,契约 0.3.0 2026-09-11 14:56:30 +08:00
Rick Ye e958784d10 docs: 新增常见错误速查页(症状→原因→修复)+ 明确重载宿主级粒度;L4/L7/索引交叉引用 2026-09-11 13:49:34 +08:00
Rick Ye 5ee00bd2c4 feat(ui-refactor 4/4): 表单字段切 bg-field+inset 阴影(替代描边风,宿主/插件组件库统一);流式光标常亮对齐 StreamText 2026-09-11 13:38:23 +08:00
Rick Ye 1a7d79739d feat(ui-refactor 3/4): 聊天消息进场 fade-up(原版强缓动);审批卡/Composer/ToolChips 经 token 切换自动对齐原版规格 2026-09-11 12:06:21 +08:00
Rick Ye b0a59cdadf feat(ui-refactor 2/4): DESIGN.md 重写为 beautifului 标准(token/圆角/阴影/动效/字号体系);插件组件库 Button 对齐 pill 体系 2026-09-11 12:00:46 +08:00
Rick Ye a95b775120 chore: 移除临时文件并忽略 2026-09-11 11:58:52 +08:00
Rick Ye 60fc9b6f4f feat(ui-refactor 1/4): token 基座对齐 beautifului.dev foundation 原版——蓝色 accent、ring+分层阴影、圆角体系 6/8/10/14、强缓动曲线、暗色专属 ring;Button 恢复 pill 体系 2026-09-11 11:58:27 +08:00
67 changed files with 2784 additions and 630 deletions
+1 -1
View File
@@ -10,5 +10,5 @@ release/
preview-shots/
*.tsbuildinfo
capture-log.txt
scripts/
preview/
"tmp-foundation.css"
+19 -1
View File
@@ -23,10 +23,28 @@ function copyTokensCss(): Plugin {
}
}
/** 构建期把插件存储 worker(免打包纯 ESM,只依赖 node 内置模块)拷到 out/main/ 旁:
* 主进程按 import.meta.url 定位它——必须与主进程产物同目录 */
function copyStorageWorker(): Plugin {
return {
name: 'aso-copy-storage-worker',
closeBundle() {
const src = resolve(__dirname, 'src/main/plugins/storage-worker.mjs')
const outDir = resolve(__dirname, 'out/main')
try {
mkdirSync(outDir, { recursive: true })
copyFileSync(src, resolve(outDir, 'storage-worker.mjs'))
} catch (err) {
this.warn(`storage-worker.mjs 拷贝失败: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
}
export default defineConfig({
main: {
// pi SDK 为纯 ESM 包,主进程输出 ESM(Electron ≥28 支持)
plugins: [externalizeDepsPlugin()],
plugins: [externalizeDepsPlugin(), copyStorageWorker()],
build: {
rollupOptions: {
// electron 保持内置模块导入;pi 全家桶运行时从 node_modules 以 ESM 加载,
+10
View File
@@ -470,6 +470,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')
+301 -98
View File
@@ -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')
}
@@ -452,11 +480,32 @@ class PluginHost {
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,
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 +537,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 +562,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 +593,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 +615,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 +787,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 +834,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) })
}
})
+2
View File
@@ -170,6 +170,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),
+31 -2
View File
@@ -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') {
@@ -424,8 +450,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
@@ -395,7 +395,10 @@ function AssistantTurnBase({
if (answerText.trim() && !hasAnswer) setHasAnswer(true)
return (
<div className="min-w-0 max-w-full">
<div
className="min-w-0 max-w-full"
style={{ animation: 'fade-up 400ms cubic-bezier(0.23, 1, 0.32, 1) both' }}
>
{hasProcess && (
<div className="border-b border-line pb-1">
<button
@@ -651,7 +654,11 @@ function MessageItemBase({
if (message.role === 'assistant') {
return (
<div ref={ref} className="min-w-0 max-w-full">
<div
ref={ref}
className="min-w-0 max-w-full"
style={{ animation: 'fade-up 400ms cubic-bezier(0.23, 1, 0.32, 1) both' }}
>
<AssistantTurn
messages={[message]}
tools={tools}
@@ -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,
@@ -263,7 +267,7 @@ const CUSTOM_API_OPTIONS: Array<{ value: string; label: string }> = [
]
const customInputCls =
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3'
'w-full rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3'
/** 模型设置:搜索/过滤 + @tanstack/react-virtual 虚拟列表(供应商多、模型上千时滚动不卡顿)。
* 卡片视觉按行拼装:provider 头行圆角上盖、模型行接左右边框、末行圆角下盖,行尾留组间距。
@@ -423,7 +427,7 @@ function ModelSettings({
value={keyValue}
onChange={(e) => onKeyChange(e.target.value)}
placeholder="输入 API Key"
className="flex-1 rounded-control border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-[12px] text-ink outline-none focus:border-ink-3"
className="flex-1 rounded-control bg-field shadow-inset-field px-2.5 py-1.5 font-mono text-[12px] text-ink outline-none"
/>
<button
type="button"
@@ -491,7 +495,7 @@ function ModelSettings({
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="搜索模型或提供商"
className="w-full rounded-control border border-line-strong bg-surface py-1.5 pl-8 pr-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3"
className="w-full rounded-control bg-field shadow-inset-field py-1.5 pl-8 pr-3 text-[12.5px] text-ink outline-none placeholder:text-ink-3"
/>
</div>
{loading && <span className="text-[11px] text-ink-3">加载中…</span>}
@@ -631,6 +635,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,
@@ -733,7 +855,7 @@ 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))
return (
<div
key={p.manifest.id}
@@ -760,6 +882,7 @@ function PluginsPanel({
<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}
</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>
@@ -909,7 +1032,7 @@ function PluginSettingsForm({
// 注意不要写 w-full:string/number 控件要定宽(w-56/w-28)跟 label 同行,w-full 会把 label 挤成竖排
const inputCls =
'rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3'
'rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3'
return (
<div className="space-y-1">
@@ -1023,7 +1146,7 @@ function PluginSettingsForm({
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(item.key, e.target.value)}
className="w-44 shrink-0 rounded-control border border-line-strong bg-surface px-2 py-1.5 text-[12px] text-ink outline-none focus:border-ink-3"
className="w-44 shrink-0 rounded-control bg-field shadow-inset-field px-2 py-1.5 text-[12px] text-ink outline-none"
>
{!value && <option value="">(未设置)</option>}
{item.options.map((o) => (
@@ -1419,20 +1542,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}
@@ -1511,7 +1660,10 @@ export function SettingsModal({
(() => {
const pid = tab.slice(7)
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 +1671,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,29 +4,33 @@ import { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/* 设计规范:矩形控件统一 5px 圆角(rounded-control),rounded-full 仅限
* icon 圆钮/状态点/头像。动效走全局默认 400ms + ease(@theme 注入)。 */
/* beautifului.dev 原版 Button:Pill 胶囊形是应用的核心按钮语言。显式对称
* padding(不锁高度)保证上下留白永远相等;filled 变体带一道 1px 内高光;
* 按压回缩 0.96、150ms ease-out。 */
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
export const buttonVariants = cva(
`inline-flex items-center justify-center font-medium select-none
transition-[transform,background-color,opacity]
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none`,
transition-[transform,background-color,opacity] duration-150 ease-out
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
{
variants: {
variant: {
primary: "bg-ink text-canvas hover:opacity-90",
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
accent: "bg-accent text-white hover:bg-accent-ink",
success: "bg-green text-white hover:brightness-95",
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
/* transparent until hovered — for dense toolbars/action rows */
quiet: "text-ink hover:bg-hover",
},
size: {
/* compact toolbar control — fixed height, lighter weight */
xs: "h-7 rounded-control px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action button */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-control gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-control gap-2",
/* compact toolbar pill — fixed height, lighter weight */
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action pill — 27px tall, roomy sides */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
},
},
defaultVariants: { variant: "secondary", size: "md" },
@@ -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 ? (
+2
View File
@@ -131,6 +131,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>
)
}
@@ -1,115 +1,127 @@
/* Beautiful UI foundation — design tokens 按项目设计规范(OpenAI 浅色体系)重映射。
* 规范要点:浅色界面;accent #8e8ea0(muted purple-gray);正文近黑;8px 间距网格;
* 圆角统一 5px(圆形仅限 icon 按钮/状态点/头像);动效统一 400ms + ease;
* 平面层级:surface 之间用 hairline 边框分层,仅浮层(modal/popover/审批卡)允许一层中性软阴影。
* 层级语义:--accent 负责焦点环/选区/选中态/装饰 tint;--accent-ink(加深版)负责
* 链接与可交互文本(保证 AA 对比度);--ink-3 是弱化文本的 AA 安全灰。 */
/* Beautiful UI foundation — design tokens 与 beautifului.dev 原版 foundation.css
* 对齐(MIT):冷调近白画布、白色卡片、hairline 边框、「ring + 柔和分层阴影」
* 的层级体系、蓝色 accent、克制语义色。亮暗两套完整 token,切换 = 一次重绘。
* 圆角体系:chip 6 / control 8 / card 10 / window 14 / pill 999。
* 动效:强缓动曲线(--ease-out-strong 等)+ pill 控件 150ms ease-out。 */
@import "shadow-plugin/unprefixed";
@custom-variant dark (&:where(.dark, .dark *));
/* ──────────────────────────────────────────────────────────
* OpenAI light — design tokens
* Beautiful UI — design tokens
* Cool near-white canvas, white cards, hairline borders, layered
* single-digit-opacity shadows, a neutral ink ramp, and semantic
* color used sparingly as a condiment.
* ────────────────────────────────────────────────────────── */
:root {
/* motion — 规范统一值 */
--duration-fast: 400ms;
--duration-base: 400ms;
--duration-slow: 400ms;
--ease-standard: ease;
/* motion — pill 控件/悬停反馈走 150ms ease-out;大位移动效用强曲线变量 */
--duration-fast: 150ms;
--duration-base: 220ms;
--duration-slow: 360ms;
--ease-standard: ease-out;
/* surfaces — 中性灰阶,白底为主 */
--page: #ffffff;
--canvas: #ffffff;
--surface: #ffffff;
--inset: #f4f4f5;
--hover: #f0f0f1;
--hover-2: #e6e6e8;
/* surfaces — cool, blue-tinted neutrals */
--page: oklch(0.985 0.001 286.376);
--canvas: oklch(0.961 0.002 247.84);
--surface: oklch(1 0 0);
--inset: oklch(0.979 0.002 247.839);
--hover: oklch(0.97 0.002 247.839);
--hover-2: oklch(0.933 0.003 247.86);
/* ink ramp — 正文近黑(规范 #000 的 a11y 修正版),弱化层为 AA 安全灰 */
--ink: #0d0d0d;
--ink-2: #5d5d5d;
--ink-3: #75757f;
/* ink ramp */
--ink: oklch(0.247 0.006 258.361);
--ink-2: oklch(0.506 0.01 264.477);
--ink-3: oklch(0.695 0.009 264.505);
/* borders — 实色 hairline,不用 alpha */
--line: #ececec;
--line-strong: #dcdce0;
--line-soft: #f4f4f5;
/* borders — solid and crisp, not alpha */
--line: oklch(0.946 0.003 264.542);
--line-strong: oklch(0.912 0.005 258.326);
--line-soft: oklch(0.966 0.002 264.542);
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
--field: #f4f4f5;
--stripe: rgba(13, 13, 13, 0.04);
--stripe-bg: #f7f7f8;
--field: oklch(0.961 0.001 286.375);
--stripe: oklch(0.405 0 0 / 0.075);
--stripe-bg: oklch(0.97 0 0);
/* accent — 规范主色 #8e8ea0 及其加深可交互版 */
--accent: #8e8ea0;
--accent-ink: #707084;
--accent-tint: rgba(142, 142, 160, 0.12);
/* accent */
--accent: oklch(0.626 0.205 254.947);
--accent-ink: oklch(0.556 0.187 255.617);
--accent-tint: oklch(0.96 0.019 252.878);
/* semantic — 唯一允许的彩色例外(状态语义),已降饱和融入中性界面 */
--green: #2e7d5b;
--green-tint: #e9f3ee;
--orange: #b45309;
--orange-tint: #faf1e3;
--red: #b91c1c;
--red-tint: #fbecec;
/* semantic */
--green: oklch(0.603 0.155 150.883);
--green-tint: oklch(0.958 0.017 159.118);
--orange: oklch(0.689 0.179 49.902);
--orange-tint: oklch(0.964 0.021 67.581);
--red: oklch(0.621 0.192 23.042);
--red-tint: oklch(0.956 0.017 17.462);
/* chart overlays */
--tooltip-bg: #2a2a2e;
--tooltip-fg: #f7f7f8;
--tooltip-muted: #a5a5af;
--tooltip-border: #454549;
/* chart overlays — neutral system colors with enough contrast on either theme */
--tooltip-bg: oklch(0.272 0.008 264.435);
--tooltip-fg: oklch(0.976 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.356 0.007 264.474);
/* shadows — 平面优先:hairline 分层;仅浮层保留一层中性软阴影(文档化例外) */
/* shadows — solid hairline ring + shadow-plugin's smooth layered stacks
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
* consistently across the app. */
--shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong);
--shadow-card: 0 0 0 1px var(--line);
--shadow-raised: 0 0 0 1px var(--line-strong);
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 12px 32px rgba(13, 13, 13, 0.08);
--shadow-inset-field: inset 0 0 0 1px var(--line-strong);
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
}
.dark {
--page: #212121;
--canvas: #212121;
--surface: #2f2f2f;
--inset: #181818;
--hover: #2f2f2f;
--hover-2: #424242;
--page: oklch(0.209 0.004 264.477);
--canvas: oklch(0.231 0.004 264.487);
--surface: oklch(0.26 0.006 271.191);
--inset: oklch(0.243 0.004 264.492);
--hover: oklch(0.289 0.006 271.22);
--hover-2: oklch(0.318 0.007 274.747);
--ink: #ececec;
--ink-2: #b4b4b4;
--ink-3: #8e8ea0;
--ink: oklch(0.964 0.002 247.839);
--ink-2: oklch(0.731 0.008 260.731);
--ink-3: oklch(0.541 0.01 264.484);
--line: #383838;
--line-strong: #4a4a4a;
--line-soft: #2a2a2a;
--field: #2f2f2f;
--stripe: rgba(255, 255, 255, 0.05);
--stripe-bg: #262626;
--line: oklch(0.308 0.006 258.354);
--line-strong: oklch(0.356 0.007 264.474);
--line-soft: oklch(0.278 0.006 258.354);
--field: oklch(0.293 0.006 271.223);
--stripe: oklch(1 0 0 / 0.055);
--stripe-bg: oklch(0.226 0.004 264.485);
--accent: #a3a3b5;
--accent-ink: #bcbccc;
--accent-tint: rgba(142, 142, 160, 0.18);
--accent: oklch(0.68 0.173 253.301);
--accent-ink: oklch(0.788 0.113 248.33);
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
--green: #6fbf95;
--green-tint: rgba(111, 191, 149, 0.15);
--orange: #d19a55;
--orange-tint: rgba(209, 154, 85, 0.15);
--red: #e07a70;
--red-tint: rgba(224, 122, 112, 0.15);
--green: oklch(0.705 0.154 153.814);
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
--orange: oklch(0.746 0.156 55.642);
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
--red: oklch(0.666 0.18 21.433);
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
--tooltip-bg: #171717;
--tooltip-fg: #ececec;
--tooltip-muted: #8e8ea0;
--tooltip-border: #383838;
--tooltip-bg: oklch(0.182 0.004 264.459);
--tooltip-fg: oklch(0.964 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.308 0.006 258.354);
--shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong);
--shadow-card: 0 0 0 1px var(--line);
--shadow-raised: 0 0 0 1px var(--line-strong);
--shadow-overlay: 0 0 0 1px var(--line-strong), 0 16px 40px rgba(0, 0, 0, 0.45);
--shadow-inset-field: inset 0 0 0 1px var(--line-strong);
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
* paints over the surface behind the element, so a low-alpha white edge
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
* graduated by how far the surface floats. */
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
--shadow-card:
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
0 2px 6px oklch(0 0 0 / 0.2);
--shadow-raised:
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
--shadow-overlay:
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
}
@theme inline {
@@ -146,20 +158,20 @@
/* type 沿用 globals.css 中的 --font-sans / --font-mono,不覆盖 */
/* radii — 规范统一 5px;rounded-full 仅限 icon 圆钮/状态点/头像 */
--radius-chip: 5px;
--radius-control: 5px;
--radius-card: 5px;
--radius-window: 5px;
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
--radius-chip: 6px;
--radius-control: 8px;
--radius-card: 10px;
--radius-window: 14px;
/* easing — 规范统一 ease;保留旧变量名以兼容 primitives */
--ease-out-strong: ease;
--ease-in-out-strong: ease;
--ease-link: ease;
/* easing — strong curves; built-ins are too weak */
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
/* motion — Tailwind transition-* 工具类的全局默认:400ms + ease */
--default-transition-duration: 400ms;
--default-transition-timing-function: ease;
/* motion — pill 控件/悬停反馈的全局默认:150ms ease-out(原版手感) */
--default-transition-duration: 150ms;
--default-transition-timing-function: ease-out;
}
/* ── base ─────────────────────────────────────────────── */
@@ -170,6 +182,43 @@ html {
text-rendering: optimizeLegibility;
}
body {
color: var(--ink);
font-feature-settings: "cv11", "ss01";
letter-spacing: -0.01em;
}
::selection {
background: var(--accent-tint);
color: var(--ink);
}
/* headings balance; descriptions avoid orphans */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
/* underlines clear descenders */
a {
text-underline-position: from-font;
text-decoration-skip-ink: auto;
}
/* theme switches swap every token at once — freeze transitions so the flip
* is one clean repaint instead of hundreds of mismatched color fades */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
/* Tailwind v4 resets buttons to cursor:default — anything
* clickable must invite the click */
button:not(:disabled),
@@ -207,7 +256,7 @@ select:focus-visible,
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--duration-base) var(--ease-standard);
transition: transform 280ms var(--ease-link);
}
a:hover .animated-underline::after,
@@ -216,23 +265,24 @@ a:focus-visible .animated-underline::after {
}
/* ── primitive spacing system ─────────────────────────────
* 8px 网格上的共享间距。
* Shared optical offsets for small cards. Use these
* instead of hand-tuning 10/12/14px per component.
*/
.primitive-card-pad {
padding: 8px;
padding: 12px;
}
.primitive-card-bar {
padding: 8px;
padding: 10px 12px;
}
.primitive-card-footer {
padding: 8px;
padding: 10px;
}
.primitive-table-cell {
padding: 8px;
padding: 10px 12px;
}
.primitive-icon-button {
@@ -244,83 +294,6 @@ a:focus-visible .animated-underline::after {
border-radius: var(--radius-control);
}
/* ── sidebar nav(beautifului.dev Sidebar Nav,官方组件级 CSS)──
* 收起态靠行宽切换保证图标对齐:.sidebar-row 固定 208px,
* data-sidebar-collapsed 时收为 36px(size-5 图标 + px-2 ×2),
* 恰好落在 52px 轨道正中;glide 高亮层同步收窄。
* --sidebar-copy-duration / --sidebar-copy-offset / --sidebar-easing
* 由 <aside data-sidebar-collapsed> 上注入。
*/
.sidebar-row {
width: 208px;
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: var(--sidebar-easing, ease);
}
.sidebar-copy {
opacity: 1;
transform: translateX(0);
transition:
opacity var(--sidebar-copy-duration, 180ms) ease-out,
transform var(--sidebar-copy-duration, 180ms) var(--sidebar-easing, ease);
}
.sidebar-logo,
.sidebar-collapse-control,
.sidebar-expand-control {
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: ease-out;
}
.sidebar-expand-control {
pointer-events: none;
opacity: 0;
}
.sidebar-glide-highlight {
right: 8px;
left: 8px;
}
[data-sidebar-collapsed='true'] .sidebar-row {
width: 36px;
}
[data-sidebar-collapsed='true'] .sidebar-copy {
pointer-events: none;
opacity: 0;
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
}
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
pointer-events: none;
}
[data-sidebar-collapsed='true'] .sidebar-logo,
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
pointer-events: none;
opacity: 0;
}
[data-sidebar-collapsed='true'] .sidebar-expand-control {
pointer-events: auto;
opacity: 1;
}
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
right: auto;
width: 36px;
}
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
/* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text {
@@ -391,3 +364,61 @@ a:focus-visible .animated-underline::after {
transition-duration: 0.01ms !important;
}
}
/* ── 项目机制类:侧栏折叠(对应 SidebarNav 原版 CSS,含应用内专用入口) ── */
.sidebar-logo,
.sidebar-collapse-control,
.sidebar-expand-control {
transition-duration: var(--sidebar-copy-duration, 180ms);
transition-timing-function: ease-out;
}
.sidebar-expand-control {
pointer-events: none;
opacity: 0;
}
.sidebar-glide-highlight {
right: 8px;
left: 8px;
}
[data-sidebar-collapsed='true'] .sidebar-row {
width: 36px;
}
[data-sidebar-collapsed='true'] .sidebar-copy {
pointer-events: none;
opacity: 0;
transform: translateX(calc(var(--sidebar-copy-offset, 8px) * -1));
}
[data-sidebar-collapsed='true'] .sidebar-workspace-control {
pointer-events: none;
}
[data-sidebar-collapsed='true'] .sidebar-logo,
[data-sidebar-collapsed='true'] .sidebar-collapse-control {
pointer-events: none;
opacity: 0;
}
[data-sidebar-collapsed='true'] .sidebar-expand-control {
pointer-events: auto;
opacity: 1;
}
[data-sidebar-collapsed='true'] .sidebar-glide-highlight {
right: auto;
width: 36px;
}
/* 折叠态专属元素(会话切换入口):展开时彻底不占位,折叠时以 flex 显示 */
.sidebar-collapsed-only {
display: none;
}
[data-sidebar-collapsed='true'] .sidebar-collapsed-only {
display: flex;
}
@@ -15,7 +15,7 @@ body,
* {
scrollbar-width: thin;
scrollbar-color: rgba(142, 142, 160, 0.35) transparent;
scrollbar-color: color-mix(in srgb, var(--ink-3) 45%, transparent) transparent;
}
*::-webkit-scrollbar {
@@ -24,36 +24,23 @@ body,
}
*::-webkit-scrollbar-thumb {
background: rgba(142, 142, 160, 0.35);
background: color-mix(in srgb, var(--ink-3) 45%, transparent);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(142, 142, 160, 0.55);
background: color-mix(in srgb, var(--ink-3) 70%, transparent);
}
*::-webkit-scrollbar-track {
background: transparent;
}
::selection {
background: rgba(142, 142, 160, 0.28);
}
/* 流式输出光标 */
@keyframes caret-blink {
0%,
60% {
opacity: 1;
}
61%,
100% {
opacity: 0;
}
}
/* ::selection 在 beautifului.css 基座(accent-tint + ink) */
/* 流式输出光标:以 ::after 附加到流式正文最后一个文本块的最后一行文字之后
(inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失 */
(inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失。
对齐 beautifului StreamText:流式期间光标常亮(不闪烁)。 */
.streaming-body[data-streaming='true'] > *:last-child > :is(p, h1, h2, h3, h4, h5, h6):last-child::after,
.streaming-body[data-streaming='true'] > *:last-child > :is(ul, ol):last-child > li:last-child::after {
content: '';
@@ -64,7 +51,6 @@ body,
vertical-align: -0.15em;
background: var(--ink);
border-radius: 1px;
animation: caret-blink 0.9s steps(1) infinite;
}
/* 禁用 textarea 的默认 resize 与滚动条闪烁 */
+18 -1
View File
@@ -361,7 +361,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 {
@@ -382,6 +393,10 @@ export interface PluginInfo {
hasSettings: boolean
/** 设置入口已固定到设置页一级导航(用户开关;仅 hasSettings 时可开) */
settingsNav: boolean
/** 依赖清单及实时状态(manifest.dependencies + optionalDependencies 求值;无声明则空数组) */
dependencies: PluginDepStatus[]
/** 已声明的存储 schema(api.storage.declareSchema;未声明则缺省) */
storageSchema?: PluginStorageSchema
/** 装载/激活错误(不影响其他插件) */
error?: string
}
@@ -391,6 +406,8 @@ export interface PluginStatusEntry {
pluginId: string
itemId: string
item: StatusItem
/** 最近一次写入时间(宿主记录;悬浮胶囊据此展示"插件最新消息") */
updatedAt: number
}
/** 工具栏按钮条目 */
+22 -7
View File
@@ -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)。
+8 -5
View File
@@ -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: '进行中', // 分组标题可省略
+2
View File
@@ -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) 的类名词汇,禁止硬编码色值/阴影。
+10 -1
View File
@@ -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)
+99
View File
@@ -0,0 +1,99 @@
# 附 · 常见错误速查(排障表)
> 面向「正在排障的 Agent/开发者」。**症状 → 原因 → 修复**三段式,按报错文本 grep 即可定位。
> 日志第一入口:`userData/logs/main-YYYY-MM-DD.log`(scope=plugin 的行带 pluginId,见 [L5](./06-self-diagnosis.md))。
> 改完任何文件后跑 `pnpm plugin:check <插件目录>`,再点插件页的「重载」。
## 1. 插件没出现在列表里
| 症状 | 原因 | 修复 |
|---|---|---|
| 设置 → 插件 → 对应作用域看不到插件 | 目录位置不对 | 只认三处:`userData/plugins/<id>/`、`<工作区>/.pi/extensions/<id>/`、`~/.pi/agent/extensions/<id>/`(工作区切换后项目级目录才同步) |
| 用户版构建下第三方区显示「装载已关闭」 | `EXTERNAL_PLUGINS_ENABLED` 仅开发版开启 | 用 dev 版,或启动加 `ASO_EXTERNAL_PLUGINS=1` |
| 列表有但灰着 | 插件被禁用(`aso-plugin-settings.json` disabled 列表) | 打开「启动」开关 |
| `plugin:check` 报 manifest 错误 | 缺字段 / version 非 semver / minContract 高于宿主契约 | 对照 [docs/plugins.md](../plugins.md) manifest 表;宿主契约版本以 `plugin-host.ts` 的 `HARNESS_CONTRACT_VERSION` 为准 |
| 目录里有代码但永不装载 | 目录名/入口是 `index.ts`/`index.js` | **不要用这两个名字**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载;用 `main.mjs` |
## 2. main.mjs 激活失败
| 症状 | 原因 | 修复 |
|---|---|---|
| 插件行红字「激活失败: …」,日志有 `plugin activation failed` | 工厂抛错 | 看 err 信息修代码;激活路径只做「注册」级工作 |
| 红字「激活失败: … 超时(10000ms)」 | 异步工厂挂死超过 10s | 网络等慢操作移到工具首次调用;必须等待时用 `withTimeout` 先于宿主超时 |
| 应用整体卡顿、聊天停摆 | main.mjs 里同步 IO(`*Sync`/`execSync`)阻塞主进程事件循环 | 一律换 `node:fs/promises` 与异步子进程;检验器会标 error |
| 日志里没有插件的任何输出 | 用了 `console.*`(不落盘) | 改 `api.log.*`(scope=plugin,带 pluginId) |
| 其他插件的胶囊/按钮全消失了 | 该插件激活抛错触发 `clearPluginUi` | 修复激活错误;胶囊/按钮在插件 dispose/出错时被宿主清理是预期行为 |
## 3. renderer 装载失败(React 组件)
| 症状 | 原因 | 修复 |
|---|---|---|
| 插件页/面板显示「插件界面装载失败」+ 重试按钮 | 读源或 import 失败(详见下方各行) | 按 title/description 里的 message 对照下几行 |
| message 含 `超时(8000ms)` | 读源或 import 超过 8s | 瘦身 bundle;检查渲染层不 import 巨型依赖 |
| `Minified React error #310`/`Invalid hook call`/hooks 全部报错 | 插件打包了自带 React 副本 | esbuild 加 `--alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js`(配方见 [L4](./05-renderer-components.md));产物必须含 `__ASO_PLUGIN_HOST__` |
| `Failed to fetch dynamically imported module` / 相对导入报错 | bundle 不自包含(相对导入无基路径,宿主用 Blob URL 装载) | `--bundle` 单文件产物;一切依赖打进包内(react 走别名除外) |
| 产物没被装载且无报错 | 超过 8MB 上限 | 瘦身;检查是否误打包 node_modules |
| 改了源码但界面没变 | 没重载 | 点插件行「重载」(或重开关插件);renderer 只在列表变化/重载时重装 |
## 4. Agent 能力(工具/命令/事件)不生效
| 症状 | 原因 | 修复 |
|---|---|---|
| 工具/命令没出现 | 改完代码没重载 | `plugins:reload` 是**宿主级**:重扫全部目录 + 重建运行时,所有插件会短暂重激活——改了哪个插件都跑同一个「重载」 |
| 工具在列表里但 Agent 不调用 | description 不清晰 / 参数 schema 不合法 | 按 pi 工具规范写 description 与 parameters |
| 重载后工具重复/残留 | 插件在 dispose 里没清资源 | 返回 dispose 函数清理(宿主会自动清内联 UI,但 pi 注册的资源要自己管) |
## 5. 设置与导航
| 症状 | 原因 | 修复 |
|---|---|---|
| 插件行没有「入口」行 | 未声明 settings(items 为空 = 无配置页)或插件未启动 | `api.ui.registerSettings` 声明至少一项;启动插件 |
| 配置页改了值插件没收到 | 没挂 `onSettingsChange` | 激活时订阅;值由宿主持久化并实时回推 |
| 设置页按钮点了没反应 | 没挂 `onSettingsAction` | 按 key 订阅动作回调 |
| 左侧导航「Agent 能力」没有插件入口 | `settingsNav` 未开启 | 插件行 → 入口开关打开 |
## 6. 依赖与互调(契约 0.3.0)
| 症状 | 原因 | 修复 |
|---|---|---|
| 行红字「缺少依赖插件 x(^1.0.0),请安装后重载」 | 必需依赖未安装 | 把依赖插件放入任一插件目录,点「重载」;宿主不自动安装 |
| 行红字「依赖插件 x 已停用,请先启用」 | 必需依赖被禁用 | 到该依赖插件行打开「启动」;启用后本插件自动恢复 |
| 行红字「依赖插件 x 需要 ^2.0,当前 v1.4.0」 | 版本不满足范围 | 升级依赖插件,或放宽自己 manifest 的范围 |
| 行红字「循环依赖: a → b → a」 | 必需依赖成环 | 解环(改为 optionalDependencies 或重构边界) |
| `requirePlugin` 抛「未声明对 x 的依赖」 | 未在 dependencies/optionalDependencies 声明 | 依赖声明即访问授权——manifest 里加上,并满足其 `minContract` |
| `requirePlugin` 返回 undefined(可选依赖) | 可选依赖缺失/停用/未就绪 | 预期行为;走降级路径 |
| 改了依赖方代码,依赖方行为没变 | 没重载 | 重载是宿主级动作,点同一个「重载」即可 |
## 7. 存储(SQLite)
| 症状 | 原因 | 修复 |
|---|---|---|
| `plugin:check` 报「使用了 api.storage.db 但未调用 declareSchema」 | 用了 SQLite 却没声明机器可读 schema | 激活期调用 `api.storage.declareSchema`,表/字段 description 写清楚(Agent 内省与设置页展示同源) |
| `api.storage.*` 报「存储操作超时(…5000ms)」 | worker 挂死或查询过重 | 检查 SQL 是否全表扫描/锁表;重启应用可恢复 worker |
| 报「database is locked」 | 跨进程/工具直接打开了同一个 data.db | 用宿主 API 访问,不要同时用外部 sqlite 工具长连接 |
| KV 读回来的值不对 | value 不可 JSON 序列化(Date/Map) | 存前转 JSON 兼容类型;大二进制放文件、路径存 KV |
| 想彻底重来 | — | 设置 → 插件 → 二级页「存储区」→ 清除数据(二次确认,整目录删除) |
| 表结构要改 | — | `PRAGMA user_version` 自管迁移(见 [L9](./11-storage.md) §4) |
## 8. 样式不对
| 症状 | 原因 | 修复 |
|---|---|---|
| 界面颜色和宿主不一致 / 深浅色切不过来 | 硬编码 hex/rgb | 只用 token 类词汇(DESIGN.md §2);`plugin:check` 会标 error |
| iframe 插件页没样式 | 未引宿主 token 表 | `<head>` 引 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind |
| 暗色模式下页面发白 | 用了 `prefers-color-scheme` 自测 | 主题由宿主推送(`useHarnessTheme`),删掉自测 |
| 图标是拼图(fallback) | manifest.icon 不在 lucide 白名单 | 改成白名单内的 lucide 名(DESIGN.md §3;`plugin:check` 会 warn) |
| 按钮不是胶囊形 / 圆角不对 | 手写 rounded-control 当按钮 | renderer 用组件库 `Button`(pill 体系);圆角体系 chip6/control8/card10/window14 |
## 9. 检验器对照
`pnpm plugin:check <目录>` 输出的每条 error/warn 都带修复指引;完整规则表见 [L7](./08-performance.md)。速记:
- error = 装不了或必然出问题(同步 IO、console.*、React 副本、hex、超尺寸、契约非法、依赖声明非法、用 db 未声明 schema、requirePlugin 未声明依赖)
- warn = 应当修(缺 minContract、疑似 React 副本、三态缺失、阻塞对话框、icon 不在白名单、产物未构建、声明依赖但 minContract 过低)
## 10. 还不行?
1. `pnpm plugin:check <目录>` → 0 error
2. 重载插件 → 看 `userData/logs/main-*.log` 里 `plugin activated pluginId=<id>` 之后你自己的 `api.log` 输出
3. 对照 [L5 自诊断](./06-self-diagnosis.md) 的闭环流程补日志埋点,再重试
+97
View File
@@ -0,0 +1,97 @@
# L8 插件依赖与服务互调
> 插件不是孤岛:A 依赖 B/C 时,声明、激活顺序、可用性判定、调用规范由宿主统一保证。
> 契约 0.3.0 起;全部机制在 `dependencies` / `optionalDependencies` + `api.service` / `api.requirePlugin` 四个名字里。
## 1. 声明依赖(manifest.json)
```json
{
"id": "wiki-assistant",
"version": "1.2.0",
"minContract": "0.3.0",
"dependencies": {
"fetch-tools": "^1.0.0",
"browser": "*"
},
"optionalDependencies": {
"embed-store": "~2.1.0"
}
}
```
- 值是 **semver 范围**,支持五种写法:`*`(任意)、`^1.2.0`(同主版本)、`~1.2.0`(同次版本)、`>=1.2.0`(下限)、`1.2.0`(精确)。
- `dependencies` 是**必需依赖**:缺失 / 已停用 / 版本不符 / 自身装载失败 → 本插件激活失败。
- `optionalDependencies` 是**可选依赖**:不就绪只降级,`api.requirePlugin(id)` 返回 `undefined`。
- **依赖声明即访问授权**:`requirePlugin` 只对声明过的 id 放行,未声明直接抛错。
## 2. 运行时判定(软绑定,无强行绑定)
宿主在每次激活轮(启停/重载/重建运行时)做三步:
1. **静态预检**:逐项检查必需依赖——
| 状态 | 判定 | 插件行展示的错误 |
|---|---|---|
| missing | resolved 表里没有该 id | `缺少依赖插件 fetch-tools(^1.0.0),请安装后重载` |
| disabled | 在禁用列表里 | `依赖插件 browser 已停用,请先启用` |
| version | 不满足范围 | `依赖插件 fetch-tools 需要 ^1.0.0,当前 v0.9.0` |
| error | 依赖自身装载失败 | `依赖插件 fetch-tools 装载失败,无法激活` |
2. **环检测**:必需依赖图存在环 → 整环不激活,每个成员报 `循环依赖: a → b → a`。
3. **拓扑波次激活**:依赖先激活、依赖者后激活,无依赖关系的插件**同波并行**(异步工厂限时 10s,挂死不拖垮后续波次)。依赖激活失败时,依赖方报 `依赖插件 x 未能激活`。
行为语义(有意为之):
- A 声明依赖 B:**禁用 B → A 激活失败**(错误栏写明原因);**重新启用 B → A 自动恢复**(重建运行时天然重放)。
- **禁用 A 不影响 B**——依赖不级联、不偷改用户的选择。
- **不自动安装**:缺什么报什么,装不装由用户决定(错误文案就是行动指引)。
## 3. 服务互调(api.service / api.requirePlugin)
所有插件的 main 工厂运行在同一 pi 上下文,互调是**同进程直调**(无 IPC、无序列化),宿主只提供注册表与门控:
```ts
// fetch-tools 的 main.mjs —— 暴露服务
export default definePlugin((api) => {
api.service('fetch', async (url: string, init?: RequestInit) => {
const res = await fetch(url, init)
return res.text()
})
})
// wiki-assistant 的 main.mjs —— 消费服务
export default definePlugin(async (api) => {
const fetchTools = api.requirePlugin('fetch-tools') as
| { fetch: (url: string, init?: RequestInit) => Promise<string> }
| undefined
if (!fetchTools) {
// 可选依赖未就绪:降级路径(必需依赖不会走到这里——激活前置检已拦)
}
api.pi.registerTool({
name: 'wiki_lookup',
// ...
execute: async (args) => {
// 服务方法约定 async;调用方自行限时(与宿主性能护栏同构)
const body = await withTimeout(fetchTools!.fetch(args.url), 8_000, 'wiki_lookup')
// ...
}
})
})
```
规则:
- `api.service(name, impl)`:按名键控;重载/停用时随注册表清空,重激活即重新注册。
- `api.requirePlugin(id)`:返回 `{ serviceName: impl }`;未声明 → 抛错;必需依赖未就绪 → 抛错;可选未就绪 → `undefined`。
- **服务方法约定全 async**,调用方用 `withTimeout` 限时、必要时 `withRetry`(见 L7)——被调插件慢不能变成调用方卡死。
- 依赖可以指向内置插件(如 `browser`),内置与外置同一注册表。
## 4. UI 展示
- 管理页插件行名称旁出现**链接图标**,悬停 Tooltip 列出每个依赖:id / 范围 / 必需或可选 / 实时状态(可用·绿 / 未安装·橙 / 已停用·橙 / 版本不符·橙 / 装载失败·红)。
- 依赖问题的详细错误在该插件行红字与二级页展示;「去启用」到对应插件的行上操作。
## 5. 检验器
`pnpm plugin:check` 会校验:依赖 id 合法、范围语法、不能依赖自身、不能同时出现在必需与可选、声明了依赖但 `minContract < 0.3.0`(旧宿主会静默忽略声明)、调用了 `requirePlugin` 却未声明依赖。
+88
View File
@@ -0,0 +1,88 @@
# L9 插件存储(SQLite)
> 每个插件一块**私有**持久化空间:一个 SQLite 库 + 一个自由文件目录。
> KV 简单状态、SQL 结构化数据、文件随便放;schema 必须机器可读声明——它是 Agent 自我迭代与 UI 展示的同一份事实源。
## 1. 空间布局
```
userData/plugin-data/<plugin-id>/
data.db ← SQLite 库(WAL 模式):kv 表(宿主管理)+ 插件自建表
data.db-wal ← WAL 伴生文件
<其余自由文件> ← api.storage.dir() 指向这里,插件自行读写
```
- **每插件一库**(不是共享大库):导出/清除/损坏恢复都以插件为单位,跨插件天然隔离。
- 卸载/移除插件**不删数据**(重装即恢复);删除只在设置页点「清除数据」(二次确认)时发生。
## 2. API(全部 async——底层在宿主共享 worker 线程执行,永不阻塞主进程)
```ts
export default definePlugin(async (api) => {
// ① 声明机器可读 schema(使用 db 前必须声明;plugin:check 强制)
api.storage.declareSchema({
kv: { description: '会话级偏好:ui.<key> = 用户界面偏好' },
tables: [
{
name: 'notes',
description: '用户笔记',
columns: [
{ name: 'id', type: 'TEXT', description: '主键,ulid' },
{ name: 'title', type: 'TEXT', description: '标题,非空' },
{ name: 'created_at', type: 'INTEGER', description: '创建时间,Unix 毫秒' }
]
}
]
})
// ② KV:宿主管理的 kv 表(value 走 JSON 编码,适合设置类小状态)
const last = await api.storage.kv.get<string>('ui.lastQuery')
await api.storage.kv.set('ui.lastQuery', 'postgres')
await api.storage.kv.delete('ui.lastQuery')
const keys = await api.storage.kv.keys()
// ③ 自建表:SQL 自由发挥
await api.storage.db.exec('CREATE TABLE IF NOT EXISTS notes (...)') // DDL,激活期执行
await api.storage.db.run('INSERT INTO notes (id, title) VALUES (?, ?)', ['n1', '标题'])
const rows = await api.storage.db.query('SELECT * FROM notes WHERE title LIKE ?', ['%标%'])
// 原子批量:worker 端 BEGIN/COMMIT,任一失败整体回滚
await api.storage.db.batch([
{ sql: 'INSERT INTO notes (id, title) VALUES (?, ?)', params: ['n2', 'a'] },
{ sql: 'UPDATE notes SET title = ? WHERE id = ?', params: ['b', 'n2'] }
])
// ④ 自由文件(日志快照、导出等)
const dir = api.storage.dir() // userData/plugin-data/<id>/
})
```
## 3. schema 声明为什么是硬性要求
插件会被 **Agent 自我迭代**。迭代前 Agent 需要知道「库里有什么」:
- `declareSchema` 的内容随 `plugins:list` 下发——管理页「存储区」卡片与(未来的)Agent 内省工具读的是**同一份**数据;
- 每张表、每个字段的 `description` 必填,`plugin:check` 会拒绝「用了 db 却不声明 schema」的插件;
- 写给 Agent 读的 description 请具体(「创建时间,Unix 毫秒」而不是「时间」)。
## 4. 迁移(表结构变更)
宿主不解释 schema,迁移由插件自管,惯用法:
```ts
const row = await api.storage.db.query('PRAGMA user_version')
const v = Number(row[0]?.user_version ?? 0)
if (v < 1) {
await api.storage.db.exec('ALTER TABLE notes ADD COLUMN tag TEXT')
await api.storage.db.exec('PRAGMA user_version = 1')
}
```
## 5. 设置页管理
- 二级页「存储区」卡片:schema 表格化展示(表/列/类型/描述)+ **数据目录**(资源管理器打开)+ **清除数据**(二次确认,连接关闭后整目录删除)。
## 6. 边界
- 存储只在 **main 层**提供;renderer 组件 / iframe 页需要数据时,让 main 侧经状态胶囊/设置值转发(受控 IPC 通道属后续契约)。
- KV 的 value 必须可 JSON 序列化(Date/Map 请先转换);大二进制放文件,路径存 KV。
- 跨插件**不直读对方库**:需要别人的数据 = 声明依赖 + 走对方服务(见 L8)。
+92 -54
View File
@@ -1,26 +1,32 @@
# DESIGN.md · 插件视觉一致性规范
# DESIGN.md · 插件视觉一致性规范(beautifului 审美)
> 读者是**写 UI 的 Agent/开发者**。目标:任何来源的插件,界面观感与宿主完全一致。
> 读者是**写 UI 的 Agent/开发者**。目标:任何来源的插件,界面观感与宿主完全一致,
> 达到 [beautifului.dev](https://www.beautifului.dev/)(MIT)的质感。
> 规则分两类:**强制**(可机器校验/一眼可辨)与**默认**(无充分理由不得偏离)。
> 契约问题看 [../plugins.md](../plugins.md);本文件只管"长什么样"。
> Token 单一事实源:`apps/desktop/src/renderer/src/styles/beautifului.css`(= foundation 原版),
> 组件参考源码归档在 `docs/ui-ref/beautifului/*.json`(26 个 registry)。
## 0. 两条铁律
1. **色值只允许来自 token**。任何 `#hex`/`rgb()` 硬编码(规范中列出的除外)都算违规;语义永远用 token 名表达(`text-ink-3` 而不是 `text-gray-400`)。
2. **不发明新组件形态**。先查本文 §4 标准模式;确需新形态时,用 §1 的 token 组合拼装,不引入新色/新圆角/新阴影。
1. **色值只允许来自 token**。任何 `#hex`/`rgb()`/`oklch()` 硬编码(规范中列出的除外)都算违规;
语义永远用 token 名表达(`text-ink-3` 而不是 `text-gray-400`)。
2. **不发明新组件形态**。先查本文 §4 标准模式与公共组件库;确需新形态时,
用 §1 的 token 组合拼装,不引入新色/新圆角/新阴影。
## 1. Token 表(单一事实源:`peer://assets/tokens.css` = 宿主 beautifului.css)
### 表面(背景)
### 表面(背景)——冷调近白,亮暗两套完整 token
| token | 意图 |
|---|---|
| `--page` | 页面最底层背景 |
| `--surface` | 卡片/面板表面 |
| `--canvas` | 画布(primary 按钮的文字色取这里) |
| `--surface` | 卡片/面板表面(纯白) |
| `--inset` | 内嵌区域(输入框底、预览底、代码块底) |
| `--hover` / `--hover-2` | 悬停态一级/二级 |
### 文字
### 文字——中性 ink ramp(深灰,不用纯黑)
| token | 意图 |
|---|---|
@@ -28,51 +34,90 @@
| `--ink-2` | 次级文字(正文说明) |
| `--ink-3` | 弱化文字(占位、提示、元信息、路径) |
### 边线
### 边线——实色 hairline,不用 alpha
| token | 意图 |
|---|---|
| `--line` | 常规边框(卡片、列表分隔) |
| `--line-strong` | 强边框(输入框、可点击描边按钮) |
| `--line-strong` | 强边框(输入框、可点击描边) |
| `--line-soft` | 极弱分隔 |
### 强调与语义
### 强调与语义——蓝色 accent,语义色只作"调味料"
| token | 意图 |
|---|---|
| `--accent` / `--accent-ink` / `--accent-tint` | 品牌强调(主按钮、选中、链接) |
| `--accent` / `--accent-ink` / `--accent-tint` | 焦点环/选区/选中态/链接(accent-ink 负责可交互文本,保 AA 对比) |
| `--green` / `--green-tint` | 成功/正常 |
| `--orange` / `--orange-tint` | 警示 |
| `--red` / `--red-tint` | 错误/危险 |
### 阴影(全部是描边式,禁止投影类阴影)
### 阴影——「ring + 分层柔影」,不是纯 hairline
`--shadow-hairline`(发丝线)· `--shadow-btn`(按钮)· `--shadow-card`(卡片)· `--shadow-raised`(浮起)· `--shadow-overlay`(弹层)· `--shadow-inset-field`(内嵌输入)
| token | 意图 |
|---|---|
| `--shadow-btn` | ring(line-strong) + xs——secondary 按钮 |
| `--shadow-card` | ring(line) + sm——卡片 |
| `--shadow-raised` | ring(line) + md——悬浮件 |
| `--shadow-overlay` | ring(line) + lg——弹层/模态 |
| `--shadow-inset-field` | 输入框内嵌阴影 |
| `--shadow-hairline` | 纯 ring(无投影)|
### 动效
暗色模式 ring 自动切换为低透明白渐进(0.10→0.15 按浮起高度),阴影加深——
**黑白主题不是简单反色**:暗色有一套专属 elevation 表达。
`--duration-fast/base/slow`(统一 400ms)+ `--ease-standard`。悬停/切换用 `transition-colors` 即可。
### 圆角体系(关键差异)
| token | 值 | 用途 |
|---|---|---|
| `--radius-chip` | 6px | chips、小徽标 |
| `--radius-control` | 8px | 输入框、菜单行、icon 按钮 |
| `--radius-card` | 10px | 卡片 |
| `--radius-window` | 14px | 窗口级容器(composer 外壳、弹窗) |
| `rounded-full` | 999px | **按钮(pill 是核心语言)**、状态点、头像 |
### 动效——强缓动曲线,内置 ease 太弱
| token | 值 | 用途 |
|---|---|---|
| `--ease-out-strong` | cubic-bezier(0.23, 1, 0.32, 1) | 大位移(滑层、展开、fade-up) |
| `--ease-in-out-strong` | cubic-bezier(0.77, 0, 0.175, 1) | 往返位移 |
| `--ease-link` | cubic-bezier(0.16, 1, 0.3, 1) | 下划线/微交互 |
| 控件默认 | 150ms ease-out | pill 按钮、悬停反馈 |
| 按压 | `active:scale-[0.96]` | 所有可点 pill |
## 2. 类名词汇(宿主 Tailwind 映射,renderer 组件直接用)
背景 `bg-page / bg-surface / bg-inset / bg-hover / bg-accent / bg-accent/10` ·
文字 `text-ink / text-ink-2 / text-ink-3 / text-accent / text-red` ·
背景 `bg-page / bg-canvas / bg-surface / bg-inset / bg-hover / bg-hover-2 / bg-accent / bg-accent/10` ·
文字 `text-ink / text-ink-2 / text-ink-3 / text-accent / text-accent-ink / text-red` ·
边框 `border-line / border-line-strong / border-dashed border-line` ·
圆角 `rounded-card`(卡片/输入块)`rounded-control`(按钮/输入框/徽标)。
圆角 `rounded-chip`(6)`rounded-control`(8)`rounded-card`(10)`rounded-window`(14)`rounded-full`(pill)·
阴影 `shadow-btn / shadow-card / shadow-raised / shadow-overlay`。
## 3. 字号与间距(默认)
## 3. 字号与间距
- 字号阶:`10px`(徽标)/ `11px`(辅助按钮)/ `11.5px`(次要按钮、说明)/ `12px`(正文说明) / `12.5px`(行标题/正文)/ `16px`(页面标题)。**不使用其他字号**;行高 `leading-4/5`。
- 间距基数为 4px:卡片内边距 `px-3 py-2.5`,区块间 `space-y-1.5`,小节间 `pt-3`。
- 图标一律 **lucide**(宿主白名单 `plugin-icons.tsx`,约 100 个常用名):`manifest.icon` / 状态胶囊 `icon` 填 lucide 图标名(如 `"Globe"`,kebab-case 亦可),12–16px、`currentColor` 由宿主保证;**禁止自绘 svg**(旧内联 svg 仅对存量插件兼容),未知名回落拼图图标
- 基准 14px/1.5,`letter-spacing -0.01em`;字号阶:`11px`(元信息/徽标)→ `12px`(chips、正文说明)→ `12.5/13px`(行标题、按钮)→ `14px`(正文)→ `16px`(页面标题)。**不使用其他字号**。
- 间距基数 4px:卡片内边距 `px-3 py-2.5`(紧凑)或 `primitive-card-pad`(12px),区块间 `space-y-1.5`,小节间 `pt-3`。
- 图标 12–16px 线条风格,`currentColor`;**一律 lucide**(宿主白名单 `plugin-icons.tsx`),禁止自绘 svg。
- 颜色只作"调味料":结构永远是中性 ink + line,彩色仅出现在状态/数值/选中。
## 4. 标准模式(复制即用)
## 4. 标准模式
> **renderer 组件优先用组件库**:本节模式已在 `@aso/plugin-sdk/ui` 代码化
> (`Button`/`SettingRow`/`EmptyState`/`LoadingState`/`ErrorState`/`Badge`…,
> 见 [L6](./07-ui-components.md)),直接 import,不要手写。
> **renderer 组件优先用组件库**:`@aso/plugin-sdk/ui` 的 `Button`/`SettingRow`/`LoadingState`/
> `ErrorState`/`EmptyState`/`Badge`… 已按本规范实现(见 [L6](./07-ui-components.md))。
> 下面的 HTML 形态留给 iframe 插件页作者。
### 按钮(pill 是核心语言)
```html
<!-- primary:墨底 canvas 字 + 内高光;accent:蓝底白字;secondary:白底 ring+xs -->
<button class="inline-flex h-[27px] items-center justify-center gap-1.5 rounded-full px-3
text-[13px] font-medium leading-none transition-[transform,background-color,opacity]
duration-150 ease-out active:scale-[0.96] bg-accent text-white hover:bg-accent-ink
shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]">主操作</button>
<!-- quiet:透明直到悬停(密集工具行) -->
<button class="h-7 rounded-full px-2.5 text-[12px] text-ink hover:bg-hover">次要</button>
```
### 设置行(label 左 / 控件右)
```html
@@ -85,52 +130,45 @@
</div>
```
### 按钮
### 卡片(window 级容器用 14px)
```html
<!-- primary -->
<button class="rounded-control bg-accent px-3 py-1.5 text-[11.5px] font-medium text-white hover:opacity-90">主操作</button>
<!-- ghost -->
<button class="rounded-control border border-line-strong px-3 py-1.5 text-[11.5px] text-ink hover:bg-hover">次要操作</button>
<div class="rounded-window border border-line bg-surface shadow-card">…</div>
```
### 徽标
### 徽标 / 空态 / 开关
```html
<span class="rounded-control bg-hover px-1.5 py-0.5 text-[10px] text-ink-3">全局 v0.1.0</span>
```
徽标 `rounded-chip bg-hover px-1.5 py-0.5 text-[10px] text-ink-3`;
空态 = 虚线卡(同旧版);开关 `h-5 w-9`(行内)/ `h-6 w-10`(表单级),
滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。
### 空态
## 5. 动效范式
```html
<div class="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center">
<div class="text-[13px] font-medium text-ink-2">暂无数据</div>
<div class="text-[11.5px] text-ink-3">怎么产生的提示</div>
</div>
```
- 悬停反馈:150ms ease-out,只变 background-color/text(配 `transition-colors`)
- 内容进场:`fade-up 400ms var(--ease-out-strong)`(聊天消息、卡片)
- 弹层:`pop-in`(scale 0.95→1)+ `shadow-overlay`
- 滑动高亮层(菜单/标签条):220ms `cubic-bezier(0.23,1,0.32,1)`(GlideMenu)
- 流式输出:光标 2px `--ink` 常亮→结束后闪烁;`prefers-reduced-motion` 全局降级
### 开关(switch)
## 6. iframe 插件页附加规则
宿主规格:`h-5 w-9`(行内)/ `h-6 w-10`(表单级),滑块 `size-4/5 rounded-full bg-white shadow`,开 `bg-accent`、关 `bg-line-strong`。
- `<head>` 必须引入 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind
- 主题跟随宿主推送(`useHarnessTheme`),**禁止** `prefers-color-scheme` 自测;亮暗两套 token 都要核对
- 与宿主的视觉差异应趋近于零:同 token + 同类名词汇 + 同字号阶 + 同圆角/阴影体系
## 5. iframe 插件页附加规则
- `<head>` 必须引入 `<link rel="stylesheet" href="peer://assets/tokens.css" />`,并用同名 token 编译自己的 Tailwind(token 名 → 变量:`page/ink/ink-2/ink-3/line/line-strong/accent…`)
- 主题跟随宿主推送(`useHarnessTheme`),**禁止** `prefers-color-scheme` 自测
- 与宿主的视觉差异应趋近于零:同 token + 同类名词汇 + 同字号阶
## 6. renderer 组件附加规则
## 7. renderer 组件附加规则
- 类名直接用宿主词汇(§2),无需引样式;**优先 import 公共组件库**([L6](./07-ui-components.md)),不要手写标准模式
- React 必须来自 `ctx.react`(见 [L4](./05-renderer-components.md)),否则 hooks 失效
- 禁止操作宿主 DOM(document 全局查询/改样式),一切交互限制在自己的组件子树
- 机器关卡:`pnpm plugin:check <插件目录>`(hex 色值 / React 副本 / 三态缺失等,见 [L7](./08-performance.md))
## 7. 自检清单(提发布前)
## 8. 自检清单(提发布前)
- [ ] `pnpm plugin:check <插件目录>` 0 error(机器关卡,见 [L7](./08-performance.md))
- [ ] 无硬编码色值/阴影/圆角(铁律 1)
- [ ] 字号都在 §3 的阶上
- [ ] 深浅色都检查过(切主题看一眼;token 正确时自动成立)
- [ ] 圆角落在体系上:chip 6 / control 8 / card 10 / window 14 / pill 999
- [ ] 字号都在 §3 的阶上;按钮是 pill(rounded-full)
- [ ] 深浅色都检查过(暗色不是反色:ring 是低透白、阴影更深)
- [ ] 复用了 §4 模式(renderer 直接用组件库)而非新造形态
- [ ] iframe 页引了 tokens.css 且无 `prefers-color-scheme`
+5
View File
@@ -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
View File
@@ -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 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、图标统一 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 即恢复。
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "button",
"type": "registry:ui",
"title": "Button",
"description": "Shared building block used by other Beautiful UI components.",
"dependencies": [
"class-variance-authority"
],
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/Button.tsx",
"content": "\"use client\";\n\nimport { ButtonHTMLAttributes } from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst filledShadow = \"shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]\";\n\n/* Pill-shaped by default — the app's core button style. Explicit symmetric\n * padding (not a fixed height) so the top/bottom spacing is always equal. */\nexport const buttonVariants = cva(\n `inline-flex items-center justify-center font-medium select-none\n transition-[transform,background-color,opacity] duration-150 ease-out\n active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,\n {\n variants: {\n variant: {\n primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,\n secondary: \"bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover\",\n ghost: \"bg-hover-2 text-ink hover:bg-line-strong\",\n accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,\n success: `bg-green text-white hover:brightness-95 ${filledShadow}`,\n /* transparent until hovered — for dense toolbars/action rows */\n quiet: \"text-ink hover:bg-hover\",\n },\n size: {\n /* compact toolbar pill — fixed height, lighter weight */\n xs: \"h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1\",\n /* canonical action pill — 27px tall, roomy sides */\n sm: \"h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5\",\n md: \"px-4 py-[9px] text-sm leading-none rounded-full gap-2\",\n },\n },\n defaultVariants: { variant: \"secondary\", size: \"md\" },\n },\n);\n\nexport type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>[\"variant\"]>;\n\nexport function Button({\n variant,\n size,\n className,\n ...props\n}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {\n return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;\n}\n",
"type": "registry:ui",
"target": "components/atoms/Button.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "context-cards",
"type": "registry:component",
"title": "Context Cards",
"description": "Retrieved knowledge chunks with their sources.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/primitives/ContextCards.tsx",
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * CONTEXT CARDS\n * Retrieved chunks enter once, then remain available.\n * ───────────────────────────────────────────────────────── */\n\nexport type ContextChunk = {\n title: string;\n chars: string;\n body: string;\n source: string;\n badge: string;\n tone: string;\n};\n\nexport type ContextCardsLabels = {\n header: string;\n count: string;\n};\n\nconst DEFAULT_LABELS: ContextCardsLabels = {\n header: \"All chunks\",\n count: \"32\",\n};\n\nconst CHUNKS: ContextChunk[] = [\n {\n title: \"Vendor onboarding rule\",\n chars: \"290 characters\",\n body: \"Cold-chain certification must be verified before a new dairy can be added to the reorder workflow.\",\n source: \"Dairy Onboarding SOP.pdf\",\n badge: \"PDF\",\n tone: \"bg-red\",\n },\n {\n title: \"Seasonal demand row\",\n chars: \"1,250 characters\",\n body: \"Q4 velocity table: pistachio +18%, vanilla +6%, rocky road -11%; retire flavors below 40 scoops weekly.\",\n source: \"Sales Velocity Export.csv\",\n badge: \"CSV\",\n tone: \"bg-green\",\n },\n];\n\nexport default function ContextCards({\n chunks = CHUNKS,\n labels,\n className,\n}: {\n /** Accepted for gallery/registry parity; ContextCards has no visual variants. */\n variant?: string;\n chunks?: ContextChunk[];\n labels?: Partial<ContextCardsLabels>;\n className?: string;\n} = {}) {\n const [chipsShown, setChipsShown] = useState(false);\n const copy = { ...DEFAULT_LABELS, ...labels };\n\n useEffect(() => {\n const chips = setTimeout(() => setChipsShown(true), 700);\n return () => clearTimeout(chips);\n }, []);\n\n return (\n <div className={`flex w-full max-w-95 flex-col gap-2${className ? ` ${className}` : \"\"}`}>\n <div\n className=\"flex items-center gap-2 px-0.5\"\n style={{ animation: \"fade-in 400ms ease-out both\" }}\n >\n <span className=\"text-[13px] font-semibold text-ink\">{copy.header}</span>\n <span className=\"inline-flex h-5 items-center rounded-md bg-inset px-1.5 text-[11.5px] font-medium text-ink-2 shadow-hairline tabular-nums\">\n {copy.count}\n </span>\n </div>\n\n {chunks.map((chunk, i) => (\n <div\n key={chunk.title}\n className=\"overflow-hidden rounded-card bg-surface shadow-card\"\n style={{\n animation: `fade-up 400ms cubic-bezier(0.23,1,0.32,1) ${i * 100}ms both`,\n }}\n >\n <div className=\"primitive-card-bar flex items-center gap-2.5 border-b border-line\">\n <span className=\"flex min-w-0 items-center gap-1.5 text-[13px] font-medium text-ink\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\"><path d=\"M4 6h16M4 12h16M4 18h10\" /></svg>\n <span className=\"truncate\">{chunk.title}</span>\n </span>\n <span className=\"ml-auto shrink-0 text-[12px] text-ink-3 tabular-nums\">{chunk.chars}</span>\n </div>\n <p className=\"px-3 pt-2 pb-1 text-[12.5px] leading-relaxed text-ink-2\">\n {chunk.body}\n </p>\n <div className=\"px-3 pb-3\">\n <span\n className=\"inline-flex h-6 items-center gap-1.5 rounded-full bg-inset px-2\n text-[12px] font-medium text-ink-2 shadow-btn\n transition-[opacity,transform,background-color] duration-300 hover:bg-hover\"\n style={{\n opacity: chipsShown ? 1 : 0,\n transform: chipsShown ? \"scale(1)\" : \"scale(0.95)\",\n transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n transitionDelay: `${i * 80}ms`,\n }}\n >\n <span className={`flex size-3.5 items-center justify-center rounded-[4px] ${chunk.tone} text-[7px] font-bold text-white`}>\n {chunk.badge}\n </span>\n {chunk.source}\n <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M7 17L17 7M7 7h10v10\" /></svg>\n </span>\n </div>\n </div>\n ))}\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/ContextCards.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "entity-chip",
"type": "registry:ui",
"title": "EntityChip",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/EntityChip.tsx",
"content": "/** Monogram mark — a colored disc with an initial or short glyph.\n * The shared building block for entity chips and monogram headings. */\nexport function Monogram({\n children,\n color = \"#e08a3c\",\n className = \"\",\n}: {\n children: React.ReactNode;\n color?: string;\n className?: string;\n}) {\n return (\n <span\n className={`flex size-4 shrink-0 items-center justify-center rounded-full\n text-[9px] font-semibold leading-none text-white ${className}`}\n style={{ background: color }}\n >\n {children}\n </span>\n );\n}\n\n/** Inline entity reference — a monogram + name in a soft field pill.\n * Names a supplier, person, or record inside running text. Softer than a\n * StatusPill (no dot, no state) and not a mono token (see Chip). */\nexport function EntityChip({\n name,\n color,\n monogram,\n className = \"\",\n}: {\n name: string;\n color?: string;\n monogram?: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`mx-0.5 inline-flex items-center gap-1 rounded-full bg-field\n py-px pl-[3px] pr-1.5 align-middle shadow-hairline ${className}`}\n >\n <Monogram color={color}>{monogram ?? name.charAt(0)}</Monogram>\n <span className=\"text-[12px] font-medium text-ink\">{name}</span>\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/EntityChip.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "glide-menu",
"type": "registry:ui",
"title": "GlideMenu",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/primitives/GlideMenu.tsx",
"content": "\"use client\";\n\nimport { useRef, useState, type ReactNode } from \"react\";\n\ntype GlideMenuProps = {\n children: ReactNode;\n className?: string;\n highlightClassName?: string;\n rowSelector?: string;\n};\n\n/** A single hover layer that glides between interactive menu rows. */\nexport default function GlideMenu({\n children,\n className = \"\",\n highlightClassName = \"inset-x-0 rounded-[8px] bg-hover\",\n rowSelector = \"[data-menu-row]\",\n}: GlideMenuProps) {\n const ref = useRef<HTMLDivElement>(null);\n const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n const [visible, setVisible] = useState(false);\n\n const moveTo = (target: EventTarget | null) => {\n const container = ref.current;\n if (!(target instanceof Element) || !container) return;\n const row = target.closest(rowSelector);\n if (!(row instanceof HTMLElement) || !container.contains(row)) return;\n const containerRect = container.getBoundingClientRect();\n const rowRect = row.getBoundingClientRect();\n setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });\n setVisible(true);\n };\n\n return (\n <div\n ref={ref}\n onMouseOver={(event) => moveTo(event.target)}\n onMouseLeave={() => setVisible(false)}\n onFocusCapture={(event) => moveTo(event.target)}\n onBlurCapture={(event) => {\n if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);\n }}\n className={`group/glide-menu relative ${className}`}\n >\n <span\n aria-hidden\n className={`pointer-events-none absolute ${highlightClassName}`}\n style={{\n top: box?.top ?? 0,\n height: box?.height ?? 0,\n opacity: box && visible ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {children}\n </div>\n );\n}\n",
"type": "registry:ui",
"target": "components/primitives/GlideMenu.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "search",
"type": "registry:component",
"title": "Search",
"description": "Command search with live filtering and an empty state.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json",
"https://www.beautifului.dev/r/glide-menu.json"
],
"files": [
{
"path": "components/primitives/SearchList.tsx",
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport GlideMenu from \"@/components/primitives/GlideMenu\";\n\n/* ─────────────────────────────────────────────────────────\n * SEARCH — command search with live filtering.\n * The field, clear action, and results are directly usable.\n * ───────────────────────────────────────────────────────── */\n\nexport type SearchItem = string;\n\nexport type SearchListLabels = {\n placeholder: string;\n ariaLabel: string;\n emptyTitle: string;\n emptyHint: string;\n};\n\nconst ITEMS: SearchItem[] = [\n \"Forecast summer demand\",\n \"Find waffle cone suppliers\",\n \"Compare seasonal flavors\",\n \"Draft flavor launch plan\",\n \"Check cold-chain status\",\n \"Audit sugar costs\",\n \"Retire low sellers\",\n];\n\nconst LABELS: SearchListLabels = {\n placeholder: \"Search flavors…\",\n ariaLabel: \"Search flavors\",\n emptyTitle: \"No results found\",\n emptyHint: \"Adjust your search to try again\",\n};\n\nexport default function SearchList({\n items = ITEMS,\n labels = LABELS,\n}: {\n items?: SearchItem[];\n labels?: SearchListLabels;\n variant?: string;\n} = {}) {\n const [query, setQuery] = useState(\"\");\n const results = query\n ? items.filter((i) => i.toLowerCase().includes(query.toLowerCase()))\n : items.slice(0, 5);\n const empty = query.length > 2 && results.length === 0;\n\n return (\n <div className=\"flex min-h-[248px] w-full max-w-72 flex-col items-stretch\">\n <div className=\"w-full self-start overflow-hidden rounded-card bg-surface shadow-raised\">\n {/* input row */}\n <div className=\"flex h-10 items-center gap-2 border-b border-line px-3 transition-colors duration-100 hover:bg-hover\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--ink-3)\" strokeWidth=\"2\" strokeLinecap=\"round\" className=\"shrink-0\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"M21 21l-4.3-4.3\" />\n </svg>\n <input\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={labels.placeholder}\n aria-label={labels.ariaLabel}\n className=\"min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-3\"\n />\n {query && (\n <button\n aria-label=\"Clear search\"\n type=\"button\"\n onClick={() => setQuery(\"\")}\n className=\"flex size-6 items-center justify-center rounded-full text-ink-3\n transition-colors duration-100 hover:bg-line/70 hover:text-ink\"\n style={{ animation: \"fade-in 150ms ease-out both\" }}\n >\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\">\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n </button>\n )}\n </div>\n\n {/* results / empty state */}\n {empty ? (\n <div className=\"flex flex-col items-center justify-center gap-1 px-4 py-8\" style={{ animation: \"fade-in 250ms ease-out both\" }}>\n <span className=\"mb-1.5 flex size-8 items-center justify-center rounded-control bg-inset text-ink-3 shadow-hairline\">\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"M21 21l-4.3-4.3\" />\n </svg>\n </span>\n <span className=\"text-[13px] font-medium text-ink\">{labels.emptyTitle}</span>\n <span className=\"text-[12px] text-ink-3\">{labels.emptyHint}</span>\n </div>\n ) : (\n <div className=\"p-1\">\n <GlideMenu className=\"flex flex-col gap-px\" highlightClassName=\"inset-x-0 rounded-[6px] bg-hover\">\n {results.map((item) => (\n <button\n key={item}\n data-menu-row\n type=\"button\"\n onClick={() => setQuery(item)}\n className=\"relative z-10 flex h-8 w-full items-center rounded-[6px] px-2 text-left text-[13px] text-ink\"\n style={{ animation: \"fade-in 200ms ease-out both\" }}\n >\n {item}\n </button>\n ))}\n </GlideMenu>\n </div>\n )}\n </div>\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/SearchList.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "shimmer",
"type": "registry:ui",
"title": "Shimmer",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/Shimmer.tsx",
"content": "/** Shimmering label — signals the agent is processing. */\nexport function Shimmer({\n children,\n className = \"\",\n}: {\n children: React.ReactNode;\n className?: string;\n}) {\n return (\n <span\n className={`inline-block bg-clip-text text-transparent ${className}`}\n style={{\n backgroundImage:\n \"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)\",\n backgroundSize: \"200% 100%\",\n animation: \"shimmer-text 1.8s linear infinite\",\n }}\n >\n {children}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/Shimmer.tsx"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "stream-text",
"type": "registry:ui",
"title": "StreamText",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/StreamText.tsx",
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAM TEXT — reusable streaming primitive\n * Reveals characters quickly (fast, like a real token stream);\n * the leading edge resolves out of a soft blur, and the caret\n * stays solid while streaming, then blinks once the text\n * settles. Inherits typography from its context, so it drops\n * into any text surface (Selection Actions, chat, etc.).\n * ───────────────────────────────────────────────────────── */\n\nexport function StreamText({\n text,\n charsPerTick = 2,\n tickMs = 9,\n blurTail = 6,\n caret = true,\n className,\n onProgress,\n onDone,\n}: {\n text: string;\n /** characters revealed per tick — higher is faster */\n charsPerTick?: number;\n /** interval between reveals, ms */\n tickMs?: number;\n /** how many trailing characters carry the soft blur edge */\n blurTail?: number;\n /** render the caret (solid while streaming, blinks once idle) */\n caret?: boolean;\n className?: string;\n /** fires each tick — useful for re-anchoring UI to reflowing text */\n onProgress?: () => void;\n /** fires once the full string is shown */\n onDone?: () => void;\n}) {\n const [count, setCount] = useState(0);\n const onProgressRef = useRef(onProgress);\n const onDoneRef = useRef(onDone);\n onProgressRef.current = onProgress;\n onDoneRef.current = onDone;\n\n useEffect(() => {\n setCount(0);\n let i = 0;\n const id = setInterval(() => {\n i = Math.min(i + charsPerTick, text.length);\n setCount(i);\n onProgressRef.current?.();\n if (i >= text.length) {\n clearInterval(id);\n onDoneRef.current?.();\n }\n }, tickMs);\n return () => clearInterval(id);\n }, [text, charsPerTick, tickMs]);\n\n const streaming = count < text.length;\n const shown = text.slice(0, count);\n const split = streaming ? Math.max(0, shown.length - blurTail) : shown.length;\n\n return (\n <span className={className}>\n {shown.slice(0, split)}\n {split < shown.length && (\n <span className=\"stream-tail\">{shown.slice(split)}</span>\n )}\n {caret && (\n <span\n aria-hidden\n className={`stream-caret${streaming ? \" is-streaming\" : \"\"}`}\n />\n )}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/StreamText.tsx"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "value-pill",
"type": "registry:ui",
"title": "ValuePill",
"description": "Shared building block used by other Beautiful UI components.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/atoms/ValuePill.tsx",
"content": "type Tone = \"neutral\" | \"green\" | \"orange\" | \"red\" | \"accent\";\n\nconst TONES: Record<Tone, { cls: string; ring: string }> = {\n neutral: { cls: \"bg-field text-ink-2\", ring: \"var(--shadow-hairline)\" },\n green: { cls: \"bg-green-tint text-green\", ring: \"0 0 0 1px color-mix(in oklch, var(--green) 28%, transparent)\" },\n orange: { cls: \"bg-orange-tint text-orange\", ring: \"0 0 0 1px color-mix(in oklch, var(--orange) 28%, transparent)\" },\n red: { cls: \"bg-red-tint text-red\", ring: \"0 0 0 1px color-mix(in oklch, var(--red) 28%, transparent)\" },\n accent: { cls: \"bg-accent-tint text-accent-ink\", ring: \"0 0 0 1px color-mix(in oklch, var(--accent) 28%, transparent)\" },\n};\n\n/** Inline value badge — a plain value (a date, a name, a count) set off in\n * prose. Softer than a StatusPill (no dot) and not a mono token (see Chip). */\nexport function ValuePill({\n children,\n tone = \"neutral\",\n className = \"\",\n}: {\n children: React.ReactNode;\n tone?: Tone;\n className?: string;\n}) {\n const t = TONES[tone];\n return (\n <span\n className={`mx-0.5 inline-flex items-center rounded-full px-1.5 py-0\n align-middle text-[12px] font-medium ${t.cls} ${className}`}\n style={{ boxShadow: t.ring }}\n >\n {children}\n </span>\n );\n}\n",
"type": "registry:ui",
"target": "components/atoms/ValuePill.tsx"
}
]
}
+1
View File
@@ -6,4 +6,5 @@ export * from './manifest'
export * from './settings'
export * from './async'
export * from './status'
export * from './storage'
export * from './plugin'
+5
View File
@@ -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
}
+9 -7
View File
@@ -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 */
+6 -2
View File
@@ -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[]
}
+57
View File
@@ -0,0 +1,57 @@
/**
* 插件存储契约(0.3.0):每插件一个 SQLite 库(userData/plugin-data/<id>/data.db),
* 由宿主共享 worker 线程持有连接——API 全 async,永不阻塞主进程。
* 本模块只含类型(同时被宿主、渲染层、插件消费),不得引入 node:sqlite。
*/
/** SQLite 存储声明中的一列(description 必填——Agent 自我迭代读的就是它) */
export interface PluginStorageColumn {
name: string
/** SQLite 类型:TEXT / INTEGER / REAL / BLOB / NUMERIC */
type: string
description: string
}
/** 插件自建表声明 */
export interface PluginStorageTable {
name: string
description: string
columns: PluginStorageColumn[]
}
/** 机器可读存储 schema:UI Tooltip 与 Agent 内省同源(plugin:check 强制描述非空)。
* kv 表由宿主管理(api.storage.get/set 的底层),这里只要求描述键空间用途。 */
export interface PluginStorageSchema {
tables: PluginStorageTable[]
kv?: { description: string }
}
/** 宿主管理的 KV 表(SQLite kv 表键值对;value JSON 编码) */
export interface PluginKvStore {
get<T>(key: string): Promise<T | undefined>
set(key: string, value: unknown): Promise<void>
delete(key: string): Promise<void>
keys(): Promise<string[]>
}
/** 插件自建表的 SQL 入口(worker 线程执行) */
export interface PluginDb {
/** 多语句 DDL/脚本(无参数) */
exec(sql: string): Promise<void>
/** 写语句(INSERT/UPDATE/DELETE/DDL,带参数) */
run(sql: string, params?: unknown[]): Promise<void>
/** 查询(返回全部行) */
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>
/** 原子批量:worker 端 BEGIN/COMMIT 包裹,任一失败整体回滚 */
batch(statements: Array<{ sql: string; params?: unknown[] }>): Promise<void>
}
/** 插件私有存储(api.storage):KV + 自建表 SQLite + 自由文件目录 + schema 声明 */
export interface PluginStorage {
kv: PluginKvStore
db: PluginDb
/** 插件数据目录(userData/plugin-data/<id>/,可自由写文件;清除数据时整体删除) */
dir(): string
/** 声明机器可读存储 schema(每次激活重新声明;使用 db 前必须声明) */
declareSchema(schema: PluginStorageSchema): void
}
+10 -8
View File
@@ -118,26 +118,28 @@ export function EmptyState({ title, description, action, className }: EmptyState
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** primary=强调实心 / ghost=描边(默认)/ danger=危险描边 */
variant?: 'primary' | 'ghost' | 'danger'
/** sm=辅助按钮 11px / md=标准 11.5px(默认) */
/** sm=辅助 pill(h-7 12px)/ md=标准 pill(13px)(默认) */
size?: 'sm' | 'md'
/** true 时禁用并显示旋转指示器(异步提交防重复点击) */
loading?: boolean
}
export function Button({ variant = 'ghost', size = 'md', loading = false, disabled, className, children, ...rest }: ButtonProps): ReactElement {
/* beautifului pill 体系:rounded-full + 150ms ease-out + 按压回缩 0.96;
* filled 变体带 1px 内高光(类已在宿主 Button.tsx 中生成,插件可直接复用)。 */
const variantCls =
variant === 'primary'
? 'bg-accent text-white hover:opacity-90'
? 'bg-ink text-canvas hover:opacity-90 shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]'
: variant === 'danger'
? 'border border-line-strong text-red hover:bg-red/10'
: 'border border-line-strong text-ink hover:bg-hover'
const sizeCls = size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-[11.5px]'
: 'bg-surface text-ink shadow-btn hover:bg-inset'
const sizeCls = size === 'sm' ? 'h-7 px-2.5 text-[12px]' : 'h-[27px] px-3 text-[12.5px]'
return (
<button
type="button"
disabled={disabled || loading}
className={cn(
'inline-flex items-center justify-center gap-1.5 rounded-control font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center gap-1.5 rounded-full font-medium leading-none transition-[transform,background-color,opacity] duration-150 ease-out active:scale-[0.96] disabled:pointer-events-none disabled:opacity-50',
variantCls,
sizeCls,
className
@@ -157,7 +159,7 @@ export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputEleme
return (
<input
className={cn(
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
'w-full rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
className
)}
{...rest}
@@ -169,7 +171,7 @@ export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLText
return (
<textarea
className={cn(
'w-full resize-y rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] leading-4 text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
'w-full resize-y bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] leading-4 text-ink outline-none placeholder:text-ink-3 disabled:opacity-50',
className
)}
{...rest}
@@ -186,7 +188,7 @@ export function Select({ options, className, ...rest }: SelectProps): ReactEleme
return (
<select
className={cn(
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none disabled:opacity-50',
'w-full rounded-control bg-field shadow-inset-field px-2.5 py-1.5 text-[12px] text-ink outline-none disabled:opacity-50',
className
)}
{...rest}
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "my-plugin",
"name": "我的插件",
"version": "0.1.0",
"minContract": "0.2.0",
"minContract": "0.3.0",
"description": "TODO:一句话描述插件做什么",
"order": 50,
"panel": true,
+260
View File
@@ -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)
+61
View File
@@ -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}`)
}
}