插件工作台卡片 (Plugin Widget Cards)
插件可以通过 contributes.widgetCards 声明工作台卡片,将数据展示在用户工作台首页。
概述
工作台卡片是插件向用户展示实时数据的入口,位于工作台(macOS 桌面小组件风格)中,与内置工具卡并列显示。
核心能力:
- 通过插件 MCP 工具按需拉取数据
- 自动刷新(
core15s /enhanced60s) - 手动刷新按钮
- 不可见时暂停刷新(IntersectionObserver)
- 增量数据合并
- 卡片库集成:用户可通过「添加卡片」弹层选择插件卡片
卡片添加流程
用户点击 "+" 添加卡片
↓
打开 CardLibrary(添加卡片弹层)
↓
┌─────────────────────────────────────┐
│ 通用操作卡 │
│ ├─ 新建任务 │
│ └─ ... │
├─────────────────────────────────────┤
│ 工作工具(当前模式) │
│ ├─ 待办清单 │
│ └─ ... │
├─────────────────────────────────────┤
│ 插件卡片 │
│ ├─ 天气卡片 (weather-widget) │
│ ├─ 宏统计 (ai-macro-recorder) │
│ └─ ... │
└─────────────────────────────────────┘
↓
选择插件卡片 → 添加到工作台
↓
PluginCardPreview 自动渲染并加载数据1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
配置方式
清单文件 (manifest.json)
json
{
"id": "ai-macro-recorder",
"name": "AI Macro Recorder",
"nameZh": "AI 宏录制器",
"contributes": {
"widgetCards": [
{
"id": "ai-macro-recorder:stats",
"icon": "CirclePlay",
"label": "Macro Stats",
"labelZh": "录制统计",
"description": "总录制次数: {{count}}",
"descriptionZh": "总录制次数:{{count}}",
"tier": "core",
"previewComponent": "MacroCardPreview",
"modes": ["work"]
}
]
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
widgetCards 字段说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 卡片唯一标识,格式 "<pluginKey>:<cardName>",如 "ai-macro-recorder:stats" |
icon | string | 是 | lucide-react 图标名(通过 IconMap 解析) |
label | string | 是 | 英文标题 |
labelZh | string | 是 | 中文标题 |
description | string | 否 | 英文描述(卡片底部显示),支持 占位符 |
descriptionZh | string | 否 | 中文描述 |
tier | 'core' | 'enhanced' | 否 | 刷新优先级:'core' = 15s,'enhanced' = 60s(默认) |
previewComponent | string | 是 | ui.js 中导出的预览组件键名 |
modes | SceneMode[] | 否 | 关联的场景模式:['work'] / ['life'] / ['study'];不填则所有模式可见 |
卡片库集成
自动显示
插件安装后,其声明的 widgetCards 会自动出现在「添加卡片」弹层的「插件卡片」分组中:
- 模式过滤:声明了
modes的卡片只在对应模式下显示 - 来源标识:显示插件 key 便于用户识别
- 已添加标识:已添加的卡片显示勾选图标并禁用
添加方式
- 点击工作台右上角「+」按钮打开 CardLibrary
- 在「插件卡片」分组中选择要添加的卡片
- 卡片自动添加到工作台并开始加载数据
移除方式
- 右键卡片 → 选择「移除」
- 或点击卡片右上角的「×」按钮
示例:CardLibrary 中的插件卡片
typescript
// CardLibrary 组件中获取插件卡片
const pluginCards = useMemo(() => {
const result = []
// 遍历所有已发现的插件
for (const [pluginKey, contribution] of pluginUiRegistry.discovered) {
if (!contribution.contributes.widgetCards?.length) continue
for (const wc of contribution.contributes.widgetCards) {
// 模式过滤
if (wc.modes?.length && !wc.modes.includes(mode)) continue
result.push({
pluginKey,
cardId: wc.id,
label: wc.label,
labelZh: wc.labelZh,
icon: wc.icon,
})
}
}
return result
}, [mode])1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
预览组件开发
预览组件是插件负责渲染卡片内容的 React 组件,通过 ui.js 导出。
组件接口
typescript
import type { WidgetCardPreviewProps } from '@aweeclaw/plugin-sdk'
interface MacroCardPreviewProps extends WidgetCardPreviewProps {
// host.callTool('getCardData', { cardId }) → { success, content }
// data 为 getCardData 返回的 JSON 解析结果
}1
2
3
4
5
6
2
3
4
5
6
WidgetCardPreviewProps
| 字段 | 类型 | 说明 |
|---|---|---|
host | PluginHostApi | 插件宿主 API,可调用 callTool、getConfig 等 |
data | Record<string, unknown> | null | 卡片数据,由框架自动拉取并增量合并 |
loading | boolean | 是否正在加载 |
onRefresh | () => void | 触发手动刷新 |
完整示例
typescript
// ui.js
import React from 'react'
import { Loader2, RefreshCw } from 'lucide-react'
/**
* MacroCardPreview — 宏录制统计卡片
*
* 数据通过 getCardData MCP 工具拉取。
* 框架自动处理:首次全量加载 → 后续增量合并 → 不可见暂停刷新。
*/
export function MacroCardPreview({ host, data, loading, onRefresh }) {
const count = data?.totalCount ?? 0
const isZh = host.language === 'zh'
return (
<div className="flex flex-col w-full h-full gap-1">
{/* 主数据 */}
{loading ? (
<div className="flex items-center gap-2 text-[11px] text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
<span>{isZh ? '加载中...' : 'Loading...'}</span>
</div>
) : (
<div className="flex items-baseline gap-1.5">
<span className="text-[22px] font-semibold leading-none text-text-primary">
{count}
</span>
<span className="text-[11px] text-text-muted">{isZh ? '次录制' : ' macros'}</span>
</div>
)}
{/* 辅助信息 */}
<div className="flex items-center justify-between mt-auto">
<span className="text-[10px] text-text-muted/70">
{isZh ? '累计录制' : 'Total recordings'}
</span>
<button
onClick={onRefresh}
title={isZh ? '刷新' : 'Refresh'}
className="p-0.5 rounded text-text-muted/60 hover:text-accent transition-colors"
>
<RefreshCw className="w-3 h-3" strokeWidth={2} />
</button>
</div>
</div>
)
}
// 导出组件映射
export default {
components: {
MacroPanel: MacroPanel, // 侧边栏面板
MacroCardPreview, // 工作台卡片预览
},
widgetCards: {
MacroCardPreview, // widgetCards 专用映射(与 components 同名 key 均可)
},
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
注意:
widgetCards映射中的组件名称必须与manifest.contributes.widgetCards[].previewComponent一致。
数据接口约定
getCardData 工具
框架通过 host.callTool('getCardData', { cardId }) 拉取数据。
插件需在 manifest 中声明此工具,或在本插件的 MCP server 中实现:
typescript
// 插件 main.js 中注册工具
const tool = {
name: 'getCardData',
description: '获取工作台卡片数据',
parameters: {
cardId: { type: 'string', description: '卡片 ID' },
},
handler: async (params) => {
const { cardId } = params
const data = await fetchCardData(cardId) // 插件自己的逻辑
return JSON.stringify(data)
},
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
返回格式
工具应返回 JSON 字符串,框架会自动解析并增量合并:
json
{
"totalCount": 42,
"lastRecording": "2026-09-02T14:30:00Z",
"status": "ready"
}1
2
3
4
5
2
3
4
5
增量合并规则:
- 首次:
data = 返回结果 - 后续:
data = { ...prevData, ...newData }(新字段覆盖旧字段,不存在的字段保留)
刷新策略详解
tier 分层
| tier | 刷新间隔 | 适用场景 |
|---|---|---|
core | 15s | 核心数据卡片,需要较高实时性(如待办数、当前状态) |
enhanced(默认) | 60s | 一般数据卡片(如统计汇总、历史记录) |
可见性感知
- 卡片进入视口 → 开始刷新
- 卡片移出视口 → 暂停刷新(IntersectionObserver,threshold: 0.1)
- 卡片再次进入 → 恢复刷新
手动刷新
- 卡片右上角显示
RotateCcw图标(仅插件卡片) - 点击后立即触发
getCardData并重新渲染 - 同时会 dispatch
plugin-card-refresh事件(cardId 为 detail)
完整插件示例
manifest.json
json
{
"id": "weather-widget",
"name": "Weather Widget",
"nameZh": "天气卡片",
"icon": "Cloud",
"type": "tool",
"version": "1.0.0",
"permissions": ["network"],
"contributes": {
"widgetCards": [
{
"id": "weather-widget:current",
"icon": "CloudSun",
"label": "Weather",
"labelZh": "天气",
"tier": "core",
"previewComponent": "WeatherCardPreview",
"modes": ["life"]
}
]
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ui.js
javascript
import React from 'react'
import { CloudSun, CloudRain, Wind, Droplets } from 'lucide-react'
function WeatherCardPreview({ host, data, loading, onRefresh }) {
const isZh = host.language === 'zh'
const temp = data?.temperature ?? '--'
const condition = data?.condition ?? 'unknown'
const iconMap = { sunny: CloudSun, rainy: CloudRain, windy: Wind, humid: Droplets }
const Icon = iconMap[condition] ?? CloudSun
return (
<div className="flex flex-col w-full h-full">
<div className="flex items-center gap-2">
<Icon className="w-5 h-5 text-yellow-400" strokeWidth={1.8} />
<span className="text-[20px] font-semibold leading-none">{temp}°C</span>
<span className="text-[11px] text-text-muted ml-auto">{condition}</span>
</div>
{data?.humidity !== undefined && (
<div className="flex items-center gap-1 mt-1 text-[10px] text-text-muted">
<Droplets className="w-3 h-3" />
<span>{data.humidity}%</span>
<span className="mx-1">·</span>
<Wind className="w-3 h-3" />
<span>{data?.windSpeed ?? '--'} km/h</span>
</div>
)}
<div className="mt-auto flex justify-end">
<button
onClick={onRefresh}
className="text-[10px] text-text-muted/60 hover:text-accent"
>
{isZh ? '刷新' : 'Refresh'}
</button>
</div>
</div>
)
}
export default {
components: { WeatherCardPreview },
widgetCards: { WeatherCardPreview },
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
main.js(注册 getCardData 工具)
javascript
export function getPlugin() {
return {
name: 'weather-widget',
tools: [
{
name: 'getCardData',
description: '获取天气卡片数据',
parameters: {
cardId: { type: 'string', description: '卡片 ID' },
},
handler: async (params, ctx) => {
const { cardId } = params
if (cardId === 'weather-widget:current') {
const res = await fetch('https://api.weather.com/current')
const data = await res.json()
return JSON.stringify({
temperature: data.temp,
condition: data.condition,
humidity: data.humidity,
windSpeed: data.windSpeed,
})
}
return JSON.stringify({})
},
},
],
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
注意事项
- 卡片 id 规范:必须为
<pluginKey>:<cardName>格式,作为工作台卡片 id 使用 - 预览组件必须导出:
ui.js需在widgetCards映射中声明对应组件,否则卡片不显示 - 数据序列化:
getCardData返回 JSON 字符串,框架自动解析;解析失败时按字符串处理 - 不可见时省电:卡片滚出视口后自动停止轮询,避免无效请求
- modes 约束:声明了
modes的卡片只在对应场景模式下显示

