fix: plugin pin preference sync across devices#8848
fix: plugin pin preference sync across devices#8848Sisyphbaous-DT-Project wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a global plugin preference service to persist pinned dashboard extensions. It adds backend API endpoints, database integration, OpenAPI specifications, and corresponding frontend SDK updates. On the frontend, the installed plugins tab is updated to synchronize pinned plugins with the backend, including debouncing and migration logic from local storage. Comprehensive tests are added for both the frontend synchronization logic and backend endpoints. Feedback on the frontend implementation suggests introducing an 'isMounted' flag in the Vue component to prevent potential memory leaks and state updates after the component is unmounted.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const schedulePersistPinnedExtensions = (delay = SAVE_DEBOUNCE_MS) => { | ||
| if (saveTimer) { | ||
| clearTimeout(saveTimer); | ||
| } | ||
| saveTimer = setTimeout(async () => { | ||
| saveTimer = null; | ||
| await persistPinnedExtensions(); | ||
| }, delay); | ||
| }; | ||
|
|
||
| const hydratePinnedPreferences = async () => { | ||
| const hydrateStartVersion = pinnedPreferenceVersion; | ||
| try { | ||
| const response = await pluginPreferencesApi.getPinnedExtensions(); | ||
| const remoteData = response?.data?.data ?? {}; | ||
| const remoteNames = normalizePinnedExtensions( | ||
| remoteData?.pinned_extensions, | ||
| ); | ||
| const localNames = pinnedExtensionNames.value; | ||
| const resolution = resolvePinnedExtensionNames({ | ||
| localNames, | ||
| remoteNames, | ||
| preferenceExists: remoteData?.preference_exists, | ||
| }); | ||
|
|
||
| if (pinnedPreferenceVersion !== hydrateStartVersion) { | ||
| return; | ||
| } | ||
|
|
||
| applyPinnedExtensionNames(resolution.names, { | ||
| markSaved: !resolution.shouldMigrate, | ||
| }); | ||
|
|
||
| if (resolution.shouldMigrate && resolution.migrateNames) { | ||
| const migrationVersion = pinnedPreferenceVersion; | ||
| const migrated = await persistPinnedExtensions(); | ||
| if ( | ||
| !migrated && | ||
| pinnedPreferenceVersion === migrationVersion && | ||
| migrationVersion > savedPinnedPreferenceVersion | ||
| ) { | ||
| schedulePersistPinnedExtensions(1000); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.warn("加载插件置顶偏好失败,继续使用本地缓存", error); | ||
| } | ||
| }; | ||
|
|
||
| onMounted(hydratePinnedPreferences); | ||
|
|
||
| onUnmounted(() => { | ||
| if (saveTimer) { | ||
| clearTimeout(saveTimer); | ||
| saveTimer = null; | ||
| } | ||
|
|
||
| void persistPinnedExtensions(); | ||
| }); |
There was a problem hiding this comment.
在组件卸载后,异步请求(如 pluginPreferencesApi.getPinnedExtensions())可能仍在进行中。当其解析时,会尝试更新组件状态并可能调度新的定时器,这会导致内存泄漏或在已卸载组件上执行不必要的操作。建议引入一个 isMounted 标志,在组件卸载时将其置为 false,并在异步回调和定时器调度中进行检查。
let isMounted = true;
const schedulePersistPinnedExtensions = (delay = SAVE_DEBOUNCE_MS) => {
if (!isMounted) return;
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(async () => {
saveTimer = null;
await persistPinnedExtensions();
}, delay);
};
const hydratePinnedPreferences = async () => {
const hydrateStartVersion = pinnedPreferenceVersion;
try {
const response = await pluginPreferencesApi.getPinnedExtensions();
if (!isMounted) return;
const remoteData = response?.data?.data ?? {};
const remoteNames = normalizePinnedExtensions(
remoteData?.pinned_extensions,
);
const localNames = pinnedExtensionNames.value;
const resolution = resolvePinnedExtensionNames({
localNames,
remoteNames,
preferenceExists: remoteData?.preference_exists,
});
if (pinnedPreferenceVersion !== hydrateStartVersion) {
return;
}
applyPinnedExtensionNames(resolution.names, {
markSaved: !resolution.shouldMigrate,
});
if (resolution.shouldMigrate && resolution.migrateNames) {
const migrationVersion = pinnedPreferenceVersion;
const migrated = await persistPinnedExtensions();
if (
!migrated &&
pinnedPreferenceVersion === migrationVersion &&
migrationVersion > savedPinnedPreferenceVersion
) {
schedulePersistPinnedExtensions(1000);
}
}
} catch (error) {
console.warn("加载插件置顶偏好失败,继续使用本地缓存", error);
}
};
onMounted(hydratePinnedPreferences);
onUnmounted(() => {
isMounted = false;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
void persistPinnedExtensions();
});
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In the OpenAPI spec,
PluginPinnedExtensionsRequest.pinned_extensions.itemsis defined as{}, which makes it effectively untyped; consider constraining it totype: stringto reflect the actual usage and align withPluginPinnedExtensionsData. - In
dashboard/src/api/v1.ts,pluginPreferencesApi.updatePinnedExtensionscurrently acceptsnames: unknown[]; tightening this tostring[](matching the schema and normalization logic) would catch misuse at compile time and better document the expected payload shape.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In the OpenAPI spec, `PluginPinnedExtensionsRequest.pinned_extensions.items` is defined as `{}`, which makes it effectively untyped; consider constraining it to `type: string` to reflect the actual usage and align with `PluginPinnedExtensionsData`.
- In `dashboard/src/api/v1.ts`, `pluginPreferencesApi.updatePinnedExtensions` currently accepts `names: unknown[]`; tightening this to `string[]` (matching the schema and normalization logic) would catch misuse at compile time and better document the expected payload shape.
## Individual Comments
### Comment 1
<location path="dashboard/src/api/v1.ts" line_range="1310" />
<code_context>
+ '/plugins/preferences/pinned',
+ );
+ },
+ updatePinnedExtensions(names: unknown[]) {
+ return apiV1Client.put<ApiEnvelope<PluginPinnedExtensionsData>>(
+ '/plugins/preferences/pinned',
+ { pinned_extensions: names },
+ );
+ },
</code_context>
<issue_to_address>
**suggestion:** Tighten the type of `updatePinnedExtensions` parameters to reflect the expected string[] payload.
The backend and OpenAPI schema expect `pinned_extensions` to be a `string[]`, but this method currently uses `names: unknown[]`, weakening type safety. Since the caller already normalizes via `normalizePinnedExtensions`, you can safely change this to `string[]` (or `readonly string[]`) to better align with the server contract and catch invalid shapes at compile time.
```suggestion
updatePinnedExtensions(names: readonly string[]) {
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| '/plugins/preferences/pinned', | ||
| ); | ||
| }, | ||
| updatePinnedExtensions(names: unknown[]) { |
There was a problem hiding this comment.
suggestion: Tighten the type of updatePinnedExtensions parameters to reflect the expected string[] payload.
The backend and OpenAPI schema expect pinned_extensions to be a string[], but this method currently uses names: unknown[], weakening type safety. Since the caller already normalizes via normalizePinnedExtensions, you can safely change this to string[] (or readonly string[]) to better align with the server contract and catch invalid shapes at compile time.
| updatePinnedExtensions(names: unknown[]) { | |
| updatePinnedExtensions(names: readonly string[]) { |
本 PR 将 Dashboard 插件置顶状态保存到后端,使不同浏览器和不同设备打开 WebUI 时可以看到一致的置顶顺序。
此前插件置顶状态只保存在浏览器
localStorage中,因此在电脑浏览器置顶插件后,换手机或其他浏览器打开 WebUI 时不会同步。Modifications / 改动点
新增 v1 Dashboard API,用于读取和更新插件置顶偏好。
使用现有后端
preferences表保存置顶插件名称列表。区分“后端尚无偏好记录”和“后端已有空置顶列表”,避免旧
localStorage数据把用户已经清空的置顶重新恢复。更新已安装插件页面:优先从后端加载置顶状态,仅在后端没有偏好记录时迁移旧本地置顶数据。
保留
localStorage作为旧数据兼容和本机缓存,不再作为跨设备同步的唯一来源。更新 OpenAPI 定义和生成的 Dashboard API 客户端文件。
增加后端和前端回归测试,覆盖偏好持久化、空列表语义、权限校验、数据库异常和迁移决策。
This is NOT a breaking change. / 这不是一个破坏性变更。
Screenshots or Test Results / 运行截图或测试结果
结果:9 passed。
.venv/bin/python -m pytest tests/test_fastapi_v1_dashboard.py -k 'plugin_preferences_pinned' -q -s --maxfail=1结果:8 passed。
结果:all checks passed。
结果:passed。
Checklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
Summary by Sourcery
Persist and sync dashboard plugin pinned preferences via a new backend API and service, ensuring consistent ordering across devices while falling back to local storage when needed.
New Features:
Bug Fixes:
Enhancements:
Tests: