feat: add plugin collection import/export#4794
feat: add plugin collection import/export#4794AstralSolipsism wants to merge 5 commits intoAstrBotDevs:masterfrom
Conversation
There was a problem hiding this comment.
Hey - 我发现了 3 个问题,并给出了一些整体反馈:
- 当前的 SensitiveFilter 会将很多通用键(例如
user、endpoint、url、host、group_id)视为敏感字段并完全丢弃,这可能会导致导出的 collection 无法复现;建议收紧启发式规则,或者允许按插件/字段进行单独关闭,以便导出时仍然可以包含必要的非机密标识符和 endpoint。 - 在 collection 导入结果弹窗中,当
priority_applied_in_memory为 true 时,priorityApplied文案始终显示为no(两个非持久化分支都映射到tm('collection.result.no')),因此用户永远看不到针对仅在内存中的优先级覆盖的正向提示;请修改该三元表达式,正确区分持久化和仅在内存中的两种情况。
给 AI Agent 的提示词
Please address the comments from this code review:
## Overall Comments
- The SensitiveFilter currently treats many generic keys (e.g. `user`, `endpoint`, `url`, `host`, `group_id`) as sensitive and drops them entirely, which may make exported collections non-reproducible; consider tightening the heuristics or allowing plugin-specific/field-specific opt-outs so required non-secret identifiers and endpoints can still be exported.
- In the collection import result dialog, the `priorityApplied` text always shows `no` when `priority_applied_in_memory` is true (both non-persisted branches map to `tm('collection.result.no')`), so users never see a positive indication for in-memory-only priority overrides; update this ternary to distinguish the persisted and in-memory cases correctly.
## Individual Comments
### Comment 1
<location> `dashboard/src/views/ExtensionPage.vue:183-192` </location>
<code_context>
+ proxy: "",
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Align `proxy` handling between UI state and the payload sent to the import API.
`collectionImport` includes a `proxy` field (set in `validateAndPreviewCollection`), but `confirmImportCollection` recomputes `proxy` via `getSelectedGitHubProxy()` and ignores `collectionImport.proxy`. This divergence is error-prone. Please either remove `collectionImport.proxy` and always use `getSelectedGitHubProxy()`, or set and read `collectionImport.proxy` consistently when constructing the import payload.
Suggested implementation:
```
const payload = {
collection: collectionImport.validatedCollection,
applyConfigs: collectionImport.applyConfigs,
overwriteExistingConfigs: collectionImport.overwriteExistingConfigs,
applyPriority: collectionImport.applyPriority,
mode: collectionImport.mode,
proxy: collectionImport.proxy,
};
```
To fully align `proxy` handling between UI state and the import payload, also ensure:
1. In `validateAndPreviewCollection` (or equivalent), you set `collectionImport.proxy` from the currently selected proxy, e.g. `collectionImport.proxy = getSelectedGitHubProxy();` at the same time you populate `collectionImport.validatedCollection`.
2. If the user can change the proxy after preview but before confirm, either:
- Update `collectionImport.proxy` whenever the proxy selection changes, or
- Re-run validation/preview when the proxy changes so `collectionImport.proxy` stays in sync.
3. Remove any other occurrences where the import payload is built using `getSelectedGitHubProxy()` directly; they should consistently use `collectionImport.proxy` instead.
</issue_to_address>
### Comment 2
<location> `astrbot/core/collection/importer.py:146-147` </location>
<code_context>
+ if not isinstance(cfg, dict):
+ continue
+
+ md = self.plugin_manager.context.get_registered_star(plugin_name)
+ if not md or not md.config:
+ continue
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid treating an empty config object as "no config" when applying imported configs.
`if not md or not md.config:` is also true when `md.config` exists but is empty, so plugins with a valid (but currently empty) config will be skipped. To distinguish "no config support" from "empty config", check explicitly for `None`, e.g. `if md is None or getattr(md, 'config', None) is None:` so those plugins can still receive imported settings.
</issue_to_address>
### Comment 3
<location> `astrbot/core/collection/importer.py:63` </location>
<code_context>
+ "conflict_detection_available": ConflictDetectionCompatibility.is_conflict_detection_available(),
+ }
+
+ async def import_collection(
+ self, collection: PluginCollection, options: ImportOptions
+ ) -> dict[str, Any]:
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the inner uninstall/install/config/reload/priority loops into focused helper methods so `import_collection` serves mainly as a high-level orchestration function.
Extract the tightly scoped loops into helpers so `import_collection` reads as orchestration rather than one monolith. For example:
```python
async def _uninstall_for_clean_mode(self, keep: set[str]) -> tuple[list[dict[str, Any]], list[str]]:
uninstalled, uninstall_failed = [], []
for star in list(self.plugin_manager.context.get_all_stars()):
if star.reserved or not star.name or star.name in keep:
continue
try:
await self.plugin_manager.uninstall_plugin(star.name)
uninstalled.append({"name": star.name, "status": "ok"})
except Exception as exc:
uninstall_failed.append(star.name)
uninstalled.append({"name": star.name, "status": "error", "message": str(exc)})
return uninstalled, uninstall_failed
```
```python
async def _install_plugins(self, collection, options, installed_before):
installed, failed, skipped = [], [], []
sem = asyncio.Semaphore(3)
async def _install_one(plugin):
async with sem:
return await self._install_plugin(plugin, options.proxy)
tasks = [
_install_one(p)
for p in collection.plugins
if not (options.import_mode == "add" and p.name in installed_before)
]
raw = await asyncio.gather(*tasks, return_exceptions=True)
...
return installed, failed, skipped
```
Then `import_collection` becomes:
```python
installed_before = {...}
uninstalled, uninstall_failed = await self._uninstall_for_clean_mode(keep) if options.import_mode == "clean" else ([], [])
install_result = await self._install_plugins(collection, options, installed_before)
config_result = await self._apply_configs(...)
reload_result = await self._reload_plugins(config_result.reload_queue)
priority_result = await self._apply_priority_overrides(...)
return {**install_result.to_dict(), **config_result.to_dict(), **reload_result.to_dict(), **priority_result.to_dict(), "conflicts": conflict_report}
```
This isolates each concern, keeps existing behavior, and reduces the state juggling inside `import_collection`.
</issue_to_address>帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续 Review。
Original comment in English
Hey - I've found 3 issues, and left some high level feedback:
- The SensitiveFilter currently treats many generic keys (e.g.
user,endpoint,url,host,group_id) as sensitive and drops them entirely, which may make exported collections non-reproducible; consider tightening the heuristics or allowing plugin-specific/field-specific opt-outs so required non-secret identifiers and endpoints can still be exported. - In the collection import result dialog, the
priorityAppliedtext always showsnowhenpriority_applied_in_memoryis true (both non-persisted branches map totm('collection.result.no')), so users never see a positive indication for in-memory-only priority overrides; update this ternary to distinguish the persisted and in-memory cases correctly.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The SensitiveFilter currently treats many generic keys (e.g. `user`, `endpoint`, `url`, `host`, `group_id`) as sensitive and drops them entirely, which may make exported collections non-reproducible; consider tightening the heuristics or allowing plugin-specific/field-specific opt-outs so required non-secret identifiers and endpoints can still be exported.
- In the collection import result dialog, the `priorityApplied` text always shows `no` when `priority_applied_in_memory` is true (both non-persisted branches map to `tm('collection.result.no')`), so users never see a positive indication for in-memory-only priority overrides; update this ternary to distinguish the persisted and in-memory cases correctly.
## Individual Comments
### Comment 1
<location> `dashboard/src/views/ExtensionPage.vue:183-192` </location>
<code_context>
+ proxy: "",
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Align `proxy` handling between UI state and the payload sent to the import API.
`collectionImport` includes a `proxy` field (set in `validateAndPreviewCollection`), but `confirmImportCollection` recomputes `proxy` via `getSelectedGitHubProxy()` and ignores `collectionImport.proxy`. This divergence is error-prone. Please either remove `collectionImport.proxy` and always use `getSelectedGitHubProxy()`, or set and read `collectionImport.proxy` consistently when constructing the import payload.
Suggested implementation:
```
const payload = {
collection: collectionImport.validatedCollection,
applyConfigs: collectionImport.applyConfigs,
overwriteExistingConfigs: collectionImport.overwriteExistingConfigs,
applyPriority: collectionImport.applyPriority,
mode: collectionImport.mode,
proxy: collectionImport.proxy,
};
```
To fully align `proxy` handling between UI state and the import payload, also ensure:
1. In `validateAndPreviewCollection` (or equivalent), you set `collectionImport.proxy` from the currently selected proxy, e.g. `collectionImport.proxy = getSelectedGitHubProxy();` at the same time you populate `collectionImport.validatedCollection`.
2. If the user can change the proxy after preview but before confirm, either:
- Update `collectionImport.proxy` whenever the proxy selection changes, or
- Re-run validation/preview when the proxy changes so `collectionImport.proxy` stays in sync.
3. Remove any other occurrences where the import payload is built using `getSelectedGitHubProxy()` directly; they should consistently use `collectionImport.proxy` instead.
</issue_to_address>
### Comment 2
<location> `astrbot/core/collection/importer.py:146-147` </location>
<code_context>
+ if not isinstance(cfg, dict):
+ continue
+
+ md = self.plugin_manager.context.get_registered_star(plugin_name)
+ if not md or not md.config:
+ continue
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid treating an empty config object as "no config" when applying imported configs.
`if not md or not md.config:` is also true when `md.config` exists but is empty, so plugins with a valid (but currently empty) config will be skipped. To distinguish "no config support" from "empty config", check explicitly for `None`, e.g. `if md is None or getattr(md, 'config', None) is None:` so those plugins can still receive imported settings.
</issue_to_address>
### Comment 3
<location> `astrbot/core/collection/importer.py:63` </location>
<code_context>
+ "conflict_detection_available": ConflictDetectionCompatibility.is_conflict_detection_available(),
+ }
+
+ async def import_collection(
+ self, collection: PluginCollection, options: ImportOptions
+ ) -> dict[str, Any]:
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the inner uninstall/install/config/reload/priority loops into focused helper methods so `import_collection` serves mainly as a high-level orchestration function.
Extract the tightly scoped loops into helpers so `import_collection` reads as orchestration rather than one monolith. For example:
```python
async def _uninstall_for_clean_mode(self, keep: set[str]) -> tuple[list[dict[str, Any]], list[str]]:
uninstalled, uninstall_failed = [], []
for star in list(self.plugin_manager.context.get_all_stars()):
if star.reserved or not star.name or star.name in keep:
continue
try:
await self.plugin_manager.uninstall_plugin(star.name)
uninstalled.append({"name": star.name, "status": "ok"})
except Exception as exc:
uninstall_failed.append(star.name)
uninstalled.append({"name": star.name, "status": "error", "message": str(exc)})
return uninstalled, uninstall_failed
```
```python
async def _install_plugins(self, collection, options, installed_before):
installed, failed, skipped = [], [], []
sem = asyncio.Semaphore(3)
async def _install_one(plugin):
async with sem:
return await self._install_plugin(plugin, options.proxy)
tasks = [
_install_one(p)
for p in collection.plugins
if not (options.import_mode == "add" and p.name in installed_before)
]
raw = await asyncio.gather(*tasks, return_exceptions=True)
...
return installed, failed, skipped
```
Then `import_collection` becomes:
```python
installed_before = {...}
uninstalled, uninstall_failed = await self._uninstall_for_clean_mode(keep) if options.import_mode == "clean" else ([], [])
install_result = await self._install_plugins(collection, options, installed_before)
config_result = await self._apply_configs(...)
reload_result = await self._reload_plugins(config_result.reload_queue)
priority_result = await self._apply_priority_overrides(...)
return {**install_result.to_dict(), **config_result.to_dict(), **reload_result.to_dict(), **priority_result.to_dict(), "conflicts": conflict_report}
```
This isolates each concern, keeps existing behavior, and reduces the state juggling inside `import_collection`.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
astrbot/core/collection/importer.py
Outdated
| md = self.plugin_manager.context.get_registered_star(plugin_name) | ||
| if not md or not md.config: |
There was a problem hiding this comment.
issue (bug_risk): 在应用导入配置时,请避免将空的配置对象误判为“没有配置”。
当 md.config 存在但为空时,if not md or not md.config: 也会为真,因此这些拥有合法(但当前为空)配置的插件会被跳过。为了区分“完全不支持配置”和“当前配置为空”,建议显式对 None 进行判断,例如:if md is None or getattr(md, 'config', None) is None:,这样这些插件仍然可以接收导入的配置。
Original comment in English
issue (bug_risk): Avoid treating an empty config object as "no config" when applying imported configs.
if not md or not md.config: is also true when md.config exists but is empty, so plugins with a valid (but currently empty) config will be skipped. To distinguish "no config support" from "empty config", check explicitly for None, e.g. if md is None or getattr(md, 'config', None) is None: so those plugins can still receive imported settings.
astrbot/core/collection/importer.py
Outdated
| "conflict_detection_available": ConflictDetectionCompatibility.is_conflict_detection_available(), | ||
| } | ||
|
|
||
| async def import_collection( |
There was a problem hiding this comment.
issue (complexity): 建议将内部卸载/安装/配置/重载/优先级这些循环抽取为职责更单一的辅助方法,让 import_collection 主要承担高层编排的角色。
可以把这些边界清晰的循环提取为 helper,使 import_collection 更像一个编排函数而不是一个大一统方法。例如:
async def _uninstall_for_clean_mode(self, keep: set[str]) -> tuple[list[dict[str, Any]], list[str]]:
uninstalled, uninstall_failed = [], []
for star in list(self.plugin_manager.context.get_all_stars()):
if star.reserved or not star.name or star.name in keep:
continue
try:
await self.plugin_manager.uninstall_plugin(star.name)
uninstalled.append({"name": star.name, "status": "ok"})
except Exception as exc:
uninstall_failed.append(star.name)
uninstalled.append({"name": star.name, "status": "error", "message": str(exc)})
return uninstalled, uninstall_failedasync def _install_plugins(self, collection, options, installed_before):
installed, failed, skipped = [], [], []
sem = asyncio.Semaphore(3)
async def _install_one(plugin):
async with sem:
return await self._install_plugin(plugin, options.proxy)
tasks = [
_install_one(p)
for p in collection.plugins
if not (options.import_mode == "add" and p.name in installed_before)
]
raw = await asyncio.gather(*tasks, return_exceptions=True)
...
return installed, failed, skipped这样 import_collection 可以变成:
installed_before = {...}
uninstalled, uninstall_failed = await self._uninstall_for_clean_mode(keep) if options.import_mode == "clean" else ([], [])
install_result = await self._install_plugins(collection, options, installed_before)
config_result = await self._apply_configs(...)
reload_result = await self._reload_plugins(config_result.reload_queue)
priority_result = await self._apply_priority_overrides(...)
return {**install_result.to_dict(), **config_result.to_dict(), **reload_result.to_dict(), **priority_result.to_dict(), "conflicts": conflict_report}这样可以将不同关注点解耦,在保持现有行为的前提下,减少 import_collection 内部对状态的反复穿插与处理。
Original comment in English
issue (complexity): Consider extracting the inner uninstall/install/config/reload/priority loops into focused helper methods so import_collection serves mainly as a high-level orchestration function.
Extract the tightly scoped loops into helpers so import_collection reads as orchestration rather than one monolith. For example:
async def _uninstall_for_clean_mode(self, keep: set[str]) -> tuple[list[dict[str, Any]], list[str]]:
uninstalled, uninstall_failed = [], []
for star in list(self.plugin_manager.context.get_all_stars()):
if star.reserved or not star.name or star.name in keep:
continue
try:
await self.plugin_manager.uninstall_plugin(star.name)
uninstalled.append({"name": star.name, "status": "ok"})
except Exception as exc:
uninstall_failed.append(star.name)
uninstalled.append({"name": star.name, "status": "error", "message": str(exc)})
return uninstalled, uninstall_failedasync def _install_plugins(self, collection, options, installed_before):
installed, failed, skipped = [], [], []
sem = asyncio.Semaphore(3)
async def _install_one(plugin):
async with sem:
return await self._install_plugin(plugin, options.proxy)
tasks = [
_install_one(p)
for p in collection.plugins
if not (options.import_mode == "add" and p.name in installed_before)
]
raw = await asyncio.gather(*tasks, return_exceptions=True)
...
return installed, failed, skippedThen import_collection becomes:
installed_before = {...}
uninstalled, uninstall_failed = await self._uninstall_for_clean_mode(keep) if options.import_mode == "clean" else ([], [])
install_result = await self._install_plugins(collection, options, installed_before)
config_result = await self._apply_configs(...)
reload_result = await self._reload_plugins(config_result.reload_queue)
priority_result = await self._apply_priority_overrides(...)
return {**install_result.to_dict(), **config_result.to_dict(), **reload_result.to_dict(), **priority_result.to_dict(), "conflicts": conflict_report}This isolates each concern, keeps existing behavior, and reduces the state juggling inside import_collection.
53530e9 to
6cd33d8
Compare
- Fix priority_applied_in_memory display text in import result dialog
There was a problem hiding this comment.
Sorry @AstralSolipsism, your pull request is larger than the review limit of 150000 diff characters
…tab-item - Revert v-window/v-window-item back to upstream v-tab-item v-show structure - Keep only necessary changes: export/import buttons and dialogs - Restore handleConflictConfirm tab value to "commands" - Restore console.log debug outputs
|
我这个合集能不能顺便把skill和mcp配置也加入整合包,一次性弄好,附带人格这些 |
|
好耶,是创意工坊 |
|
这个优先级是? |
|
目前分享一套好用配置需要手动安装插件、手动复制配置、手动调整顺序。插件合集让“可复用的一整套应用形态”能够被快速分发与复现,提高配置迁移/分享效率。

响应号召,建设“插件合集(Plugin Collection)”机制,类似 Steam 创意工坊的“合集”概念:允许用户将一套已调教好的插件组合导出为单个 JSON,并在另一端一键导入,自动完成插件安装、配置应用以及考虑兼容 PR #4716 的插件处理器优先级/排序应用。为后续建立合集市场打下基础。
Modifications / 改动点
后端新增核心模块:astrbot/core/collection/*(导入/导出、敏感过滤)。
Dashboard 新增 API:astrbot/dashboard/routes/collection.py。
WebUI 在扩展页接入:dashboard/src/views/ExtensionPage.vue(导出/导入/校验/预览/执行)。
使用 JSON Schema(Draft 7)对合集 payload 做校验。
导出时按策略过滤用户私人信息,避免不必要泄漏。
Screenshots or Test Results / 运行截图或测试结果
Checklist / 检查清单
requirements.txt和pyproject.toml文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations inrequirements.txtandpyproject.toml.Summary by Sourcery
在后端和仪表盘间添加插件集合的导入/导出工作流,以 JSON 集合的形式共享插件配置。
新功能:
增强改进:
v-window/v-window-item优化扩展页面标签内容结构,并调整冲突导航,使其打开组件标签页。ExtensionCard继承任意属性,并通过 i18n 改进处理器数量显示,对缺失的处理器数组具有更好的健壮性。Original summary in English
Summary by Sourcery
Add a plugin collection import/export workflow across backend and dashboard to share plugin setups as JSON collections.
New Features:
Enhancements: