diff --git a/apps/desktop/src/main/plugins/plugin-host.ts b/apps/desktop/src/main/plugins/plugin-host.ts index 6a2d2a2..15157b9 100644 --- a/apps/desktop/src/main/plugins/plugin-host.ts +++ b/apps/desktop/src/main/plugins/plugin-host.ts @@ -132,6 +132,8 @@ class PluginHost { /** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */ private statusItems = new Map>() private toolbarButtons = new Map>() + /** 状态条目最近写入时间(悬浮胶囊"最新消息"判定),key = pluginId\0itemId */ + private statusUpdatedAt = new Map() /** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */ private services = new Map>() /** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */ @@ -339,6 +341,7 @@ class PluginHost { log.info('reload begin') this.statusItems.clear() this.toolbarButtons.clear() + this.statusUpdatedAt.clear() this.services.clear() this.storageSchemas.clear() await this.scan() @@ -534,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) { @@ -552,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()) } @@ -581,6 +597,9 @@ class PluginHost { 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) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 2fb1c14..2e0102d 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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' @@ -424,7 +424,7 @@ export default function App(): JSX.Element { title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name} panelOpen={sideOpen} onTogglePanel={toggleSide} - extra={} + extra={} />
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */} diff --git a/apps/desktop/src/renderer/src/plugins/PluginFloatingCapsule.tsx b/apps/desktop/src/renderer/src/plugins/PluginFloatingCapsule.tsx new file mode 100644 index 0000000..37f3d90 --- /dev/null +++ b/apps/desktop/src/renderer/src/plugins/PluginFloatingCapsule.tsx @@ -0,0 +1,277 @@ +/** + * 悬浮状态胶囊(B3 槽位,复刻 ZCode「最小化药丸 ⇄ 展开面板」二态): + * - 所有插件共用一个胶囊,数据同源 plugins:uiState(api.ui.setStatusItem) + * - 宿主为每条状态记录 updatedAt:最小化药丸展示「插件最新的消息」 + * (tone 点 + 插件图标 + 文本),点击展开面板 + * - 展开面板 = 每个插件一个专属区域:区域头(图标+名称)可收起/展开 + * (记忆在 localStorage),区域内是该插件的状态条目与详情行 + * (行 = 图标 + 标签 + 次级说明 + 行尾彩色数值,行内可深链) + * - 面板高度固定,内容溢出滚动;点击面板外部 / Esc 收回最小化 + */ +import { useEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { LuChevronDown, LuMinimize2 } from 'react-icons/lu' +import type { PluginInfo, PluginStatusEntry } from '@shared/types' +import type { StatusDetail } from '@aso/plugin-sdk' +import { PluginIconValue } from './plugin-icons' + +/** 胶囊前导色点:tone 的安静表达(底保持中性,不整块染色) */ +export const TONE_DOT_CLASS: Record = { + default: 'bg-ink-3', + accent: 'bg-accent', + success: 'bg-green', + warn: 'bg-orange', + danger: 'bg-red' +} + +/** 行尾数值的着色(ZCode 式:+76 绿 / -40 红 的纯色文本,不用底色块) */ +const TRAILING_TONE_CLASS: Record = { + 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 [] + } +} + +/** 详情分组渲染(标题层由容器决定:面板里分区头是插件名,这里只渲染 sections/rows) */ +function StatusDetailBody({ + detail, + onOpenView +}: { + detail: StatusDetail + onOpenView: (view: string, params?: unknown) => void +}): ReactNode { + return ( + <> + {detail.sections.map((section, si) => ( +
0 ? 'mt-0.5' : ''}> + {section.title && ( +
{section.title}
+ )} + {section.rows.map((row, ri) => { + const clickable = Boolean(row.openView) + const content = ( + <> + {row.icon && } + + {row.label} + {row.detail && {row.detail}} + + {row.trailing && ( + + {row.trailing} + + )} + + ) + return clickable ? ( + + ) : ( +
+ {content} +
+ ) + })} +
+ ))} + + ) +} + +export function PluginFloatingCapsule({ + entries, + plugins, + onOpenView +}: { + entries: PluginStatusEntry[] + plugins: PluginInfo[] + onOpenView: (view: string, params?: unknown) => void +}): ReactNode { + const [open, setOpen] = useState(false) + const [collapsedSections, setCollapsedSections] = useState(loadCollapsedSections) + const rootRef = useRef(null) + + useEffect(() => { + localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(collapsedSections)) + }, [collapsedSections]) + + // 面板开着时:点击外部 / 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() + for (const entry of sorted) { + const arr = byPlugin.get(entry.pluginId) ?? [] + arr.push(entry) + byPlugin.set(entry.pluginId, arr) + } + + const toggleSection = (pluginId: string): void => { + setCollapsedSections((prev) => + prev.includes(pluginId) ? prev.filter((id) => id !== pluginId) : [...prev, pluginId] + ) + } + + if (!open) { + return ( +
+ +
+ ) + } + + return ( +
+
+ {/* 面板头:总量 + 收回最小化 */} +
+
+ 插件状态 + {byPlugin.size} 个插件 +
+ +
+ {/* 固定高度内容区:插件太多/内容过长向下滚动 */} +
+ {[...byPlugin.entries()].map(([pluginId, pluginEntries]) => { + const plugin = plugins.find((p) => p.manifest.id === pluginId) + const collapsed = collapsedSections.includes(pluginId) + return ( +
+ {/* 区域头:插件专属,可收起/展开 */} + + {!collapsed && ( +
+ {pluginEntries.map((entry) => { + const clickable = Boolean(entry.item.openView) + const body = ( + <> + + {entry.item.icon ? ( + + ) : null} + {entry.item.text} + + ) + return ( +
+ {clickable ? ( + + ) : ( +
{body}
+ )} + {entry.item.detail && ( +
+ +
+ )} +
+ ) + })} +
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/plugins/PluginStatusCapsules.tsx b/apps/desktop/src/renderer/src/plugins/PluginStatusCapsules.tsx deleted file mode 100644 index 82ac93d..0000000 --- a/apps/desktop/src/renderer/src/plugins/PluginStatusCapsules.tsx +++ /dev/null @@ -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 = { - default: 'bg-ink-3', - accent: 'bg-accent', - success: 'bg-green', - warn: 'bg-orange', - danger: 'bg-red' -} - -/** 行尾数值的着色(ZCode 式:+76 绿 / -40 红 的纯色文本,不用底色块) */ -const TRAILING_TONE_CLASS: Record = { - 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 ( -
-
-
{item.detail?.title ?? item.text}
- -
-
- {item.detail?.sections.map((section, si) => ( -
0 ? 'mt-1' : ''}> - {section.title && ( -
{section.title}
- )} - {section.rows.map((row, ri) => { - const clickable = Boolean(row.openView) - const content = ( - <> - {row.icon && } - - {row.label} - {row.detail && {row.detail}} - - {row.trailing && ( - - {row.trailing} - - )} - - ) - return clickable ? ( - - ) : ( -
- {content} -
- ) - })} -
- ))} -
-
- ) -} - -export function PluginStatusCapsules({ - entries, - onOpenView -}: { - entries: PluginStatusEntry[] - onOpenView: (view: string, params?: unknown) => void -}): ReactNode { - const [openKey, setOpenKey] = useState(null) - const rootRef = useRef(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 ( -
- {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 ( -
- - {open && item.detail && ( - setOpenKey(null)} /> - )} -
- ) - })} - {entries.length > 4 && ( - - +{entries.length - 4} - - )} -
- ) -} diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 233c653..4f5dc0d 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -406,6 +406,8 @@ export interface PluginStatusEntry { pluginId: string itemId: string item: StatusItem + /** 最近一次写入时间(宿主记录;悬浮胶囊据此展示"插件最新消息") */ + updatedAt: number } /** 工具栏按钮条目 */ diff --git a/docs/plugins.md b/docs/plugins.md index f4d4408..71df7e6 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -181,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` 数据驱动,标题栏**悬浮胶囊**统一承载(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),点击展开面板——每插件一个专属区域、区域可收起/展开(记忆跨启动)、固定高度溢出滚动、行尾彩色数值与行内深链 | ✅ | | 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ | | 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 | | 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 | -| 聊天区浮动胶囊(B3) | 独立浮动态暂缓——顶栏胶囊的详情面板(ZCode 式)已覆盖同场景;数据同源(`setStatusItem`) | ⏳ | ## 交互基线 diff --git a/docs/plugins/04-ui-slots.md b/docs/plugins/04-ui-slots.md index 814e08a..2d3d684 100644 --- a/docs/plugins/04-ui-slots.md +++ b/docs/plugins/04-ui-slots.md @@ -2,12 +2,15 @@ > 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。 -## 1. 状态胶囊(TitleBar 顶部) +## 1. 状态胶囊(悬浮胶囊:最小化药丸 ⇄ 展开面板) -顶栏胶囊是**紧凑状态条**,点击行为三档: +所有插件**共用一个悬浮胶囊**(标题栏右侧,ZCode 复刻)。插件照常 `setStatusItem`,宿主记录每次写入时间,两种形态: + +- **最小化药丸**:展示「最新的消息」(全部插件中 `updatedAt` 最新的一条:tone 点 + 插件图标 + 文本;多于一条时带总数角标),点击展开面板; +- **展开面板**:每个插件一个**专属区域**——区域头(插件图标 + 名称 + 条目数)可收起/展开(记忆跨启动),区域内是该插件的条目与详情行;面板高度固定,内容太多向下滚动;点击面板外部 / Esc 收回药丸。 ```js -// ① 纯展示 +// ① 纯展示(只出现在药丸/区域行) api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' }) // ② 点击深链 @@ -18,12 +21,12 @@ api.ui.setStatusItem('conn', { openView: { view: 'my-plugin' } // 点击跳到插件整页视图 }) -// ③ 点击展开详情弹层(声明了 detail 时优先于 openView) +// ③ 携详情(声明 detail 时区域行下展开分组明细) api.ui.setStatusItem('tasks', { text: '任务 2/5', tone: 'accent', detail: { - title: '任务概览', // 缺省用胶囊文本 + title: '任务概览', // 面板中省略(区域头已是插件名) sections: [ { title: '进行中', // 分组标题可省略 diff --git a/docs/roadmap.md b/docs/roadmap.md index 873dce3..98e7701 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data//data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 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//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 即恢复。