feat: re-implement plugin pinning functionality for extensions (#7918)

* feat: re-implement plugin pinning functionality for extensions

Co-authored-by: Copilot <copilot@github.com>

* chore: update subset

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Weilong Liao
2026-04-30 22:17:52 +08:00
committed by GitHub
parent 34dc91e4b0
commit d72cb78f37
15 changed files with 353 additions and 140 deletions

View File

@@ -1,10 +1,20 @@
import copy
import traceback
from sys import maxsize
import astrbot.api.message_components as Comp
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import Image, Plain
from astrbot.api.provider import LLMResponse, ProviderRequest
from astrbot.core import logger
from astrbot.core.utils.session_waiter import (
FILTERS,
USER_SESSIONS,
SessionController,
SessionWaiter,
session_waiter,
)
from .long_term_memory import LongTermMemory
@@ -18,6 +28,103 @@ class Main(star.Star):
except BaseException as e:
logger.error(f"聊天增强 err: {e}")
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) != 1:
return
is_empty_mention = (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
)
is_wake_prefix_only = (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in wake_prefix
)
if not (is_empty_mention or is_wake_prefix_only):
return
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
conversation = None
if curr_cid:
conversation = (
await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
curr_cid,
)
)
else:
curr_cid = (
await self.context.conversation_manager.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
)
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))
def ltm_enabled(self, event: AstrMessageEvent):
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"

View File

@@ -1,4 +1,4 @@
name: astrbot
desc: AstrBot 自带插件,包含人格注入、思考内容注入、群聊上下文感知等功能的实现,禁用后将无法使用这些功能。
author: Soulter
version: 4.1.0
desc: AstrBot's internal plugin, providing some basic capabilities.
author: AstrBot Team
version: 4.1.0

View File

@@ -1,4 +1,4 @@
name: builtin_commands
desc: AstrBot 自带指令,提供常用的对话管理、工具使用、插件管理等功能。
desc: AstrBot's internal plugin, providing all built-in commands such as /reset.
author: Soulter
version: 0.0.1

View File

@@ -1,115 +0,0 @@
import copy
from sys import maxsize
import astrbot.api.message_components as Comp
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.star import Context, Star
from astrbot.core.utils.session_waiter import (
FILTERS,
USER_SESSIONS,
SessionController,
SessionWaiter,
session_waiter,
)
class Main(Star):
"""会话控制"""
def __init__(self, context: Context) -> None:
super().__init__(context)
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""实现了对只有一个 @ 的消息内容的处理"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) == 1:
if (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
) or (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in wake_prefix
):
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
# 尝试使用 LLM 生成更生动的回复
# func_tools_mgr = self.context.get_llm_tool_manager()
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
conversation = None
if curr_cid:
conversation = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
curr_cid,
)
else:
# 创建新对话
curr_cid = await self.context.conversation_manager.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
# 使用 LLM 生成回复
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
# LLM 回复失败,使用原始预设回复
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
# 重新推入事件队列
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError as _:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))

View File

@@ -1,5 +0,0 @@
name: session_controller
desc: 为插件支持会话控制
author: Cvandia & Soulter
version: v1.0.1
repo: https://astrbot.app

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 257 icons */
/* Auto-generated MDI subset 259 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -784,6 +784,14 @@
content: "\F03F6";
}
.mdi-pin::before {
content: "\F0403";
}
.mdi-pin-outline::before {
content: "\F0931";
}
.mdi-play::before {
content: "\F040A";
}

View File

@@ -20,6 +20,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isPinned: {
type: Boolean,
default: false,
},
});
// 定义要发送到父组件的事件
@@ -32,6 +36,7 @@ const emit = defineEmits([
"view-handlers",
"view-readme",
"view-changelog",
"toggle-pin",
]);
const showUninstallDialog = ref(false);
@@ -115,6 +120,10 @@ const viewChangelog = () => {
emit("view-changelog", props.extension);
};
const togglePin = () => {
emit("toggle-pin", props.extension);
};
</script>
<template>
@@ -280,6 +289,22 @@ const viewChangelog = () => {
<v-card-actions class="extension-actions" @click.stop>
<template v-if="!marketMode">
<v-spacer></v-spacer>
<v-tooltip location="top">
<template v-slot:activator="{ props: pinTooltipProps }">
<v-btn
v-bind="pinTooltipProps"
:aria-label="isPinned ? tm('buttons.unpin') : tm('buttons.pin')"
:color="isPinned ? 'primary' : 'secondary'"
:icon="isPinned ? 'mdi-pin' : 'mdi-pin-outline'"
size="small"
variant="tonal"
class="extension-pin-btn"
@click="togglePin"
></v-btn>
</template>
<span>{{ isPinned ? tm("buttons.unpin") : tm("buttons.pin") }}</span>
</v-tooltip>
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
@@ -306,20 +331,6 @@ const viewChangelog = () => {
</template>
</v-tooltip>
<v-tooltip v-if="extension?.repo" location="top" :text="tm('buttons.viewRepo')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-github"
size="small"
variant="tonal"
color="secondary"
:href="extension.repo"
target="_blank"
></v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
@@ -466,6 +477,10 @@ const viewChangelog = () => {
flex-shrink: 0;
}
.extension-pin-btn {
flex-shrink: 0;
}
.extension-switch-wrap :deep(.v-switch) {
margin: 0;
}

View File

@@ -52,6 +52,8 @@
"selectFile": "Select File",
"refresh": "Refresh",
"updateAll": "Update All",
"pin": "Pin",
"unpin": "Unpin",
"deleteSource": "Delete Source",
"reshuffle": "Shuffle Again"
},

View File

@@ -52,6 +52,8 @@
"selectFile": "Выбрать файл",
"refresh": "Обновить",
"updateAll": "Обновить все",
"pin": "Закрепить",
"unpin": "Открепить",
"deleteSource": "Удалить источник",
"reshuffle": "Мне повезет!"
},

View File

@@ -52,6 +52,8 @@
"selectFile": "选择文件",
"refresh": "刷新",
"updateAll": "更新全部插件",
"pin": "置顶",
"unpin": "取消置顶",
"deleteSource": "删除源",
"reshuffle": "随机一发"
},

View File

@@ -1,6 +1,11 @@
<script setup>
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
import { normalizeTextInput } from "@/utils/inputValue";
import {
readPinnedExtensions,
writePinnedExtensions,
} from "./extensionPreferenceStorage.mjs";
import { computed, ref, watch } from "vue";
const props = defineProps({
state: {
@@ -148,6 +153,53 @@ const openPluginDetail = (extension) => {
hash: "#installed",
});
};
const pinnedExtensionNames = ref(readPinnedExtensions());
const pinnedExtensionOrder = computed(() => {
const order = new Map();
pinnedExtensionNames.value.forEach((name, index) => {
order.set(name, index);
});
return order;
});
const sortedInstalledPlugins = computed(() => {
const order = pinnedExtensionOrder.value;
return [...filteredPlugins.value].sort((a, b) => {
const aIndex = order.has(a?.name) ? order.get(a.name) : Number.POSITIVE_INFINITY;
const bIndex = order.has(b?.name) ? order.get(b.name) : Number.POSITIVE_INFINITY;
if (aIndex !== bIndex) {
return aIndex - bIndex;
}
return 0;
});
});
watch(
pinnedExtensionNames,
(names) => {
writePinnedExtensions(names);
},
{ deep: true },
);
const isPinnedExtension = (extension) => {
const name = extension?.name;
return !!name && pinnedExtensionOrder.value.has(name);
};
const togglePinnedExtension = (extension) => {
const name = extension?.name;
if (!name) return;
const next = pinnedExtensionNames.value.filter((item) => item !== name);
if (next.length === pinnedExtensionNames.value.length) {
next.unshift(name);
}
pinnedExtensionNames.value = next;
};
</script>
<template>
@@ -252,7 +304,7 @@ const openPluginDetail = (extension) => {
<v-fade-transition hide-on-leave>
<div>
<v-row v-if="filteredPlugins.length === 0" class="text-center">
<v-row v-if="sortedInstalledPlugins.length === 0" class="text-center">
<v-col cols="12" class="pa-2">
<v-icon size="64" color="info" class="mb-4"
>mdi-puzzle-outline</v-icon
@@ -274,9 +326,11 @@ const openPluginDetail = (extension) => {
>
<ExtensionCard
:extension="extension"
:is-pinned="isPinnedExtension(extension)"
class="rounded-lg"
style="background-color: rgb(var(--v-theme-mcpCardBg))"
@click="openPluginDetail(extension)"
@toggle-pin="togglePinnedExtension(extension)"
@configure="openExtensionConfig(extension.name)"
@uninstall="
(ext, options) => uninstallExtension(ext.name, options)

View File

@@ -0,0 +1,89 @@
export const PINNED_EXTENSIONS_STORAGE_KEY = "astrbot.pinnedExtensions";
const getStorageForRead = (storageOverride) => {
if (storageOverride === null) {
return null;
}
if (storageOverride !== undefined) {
return typeof storageOverride?.getItem === "function"
? storageOverride
: null;
}
if (typeof window === "undefined") {
return null;
}
try {
const localStorage = window.localStorage ?? null;
return typeof localStorage?.getItem === "function" ? localStorage : null;
} catch {
return null;
}
};
const getStorageForWrite = (storageOverride) => {
if (storageOverride === null) {
return null;
}
if (storageOverride !== undefined) {
return typeof storageOverride?.setItem === "function"
? storageOverride
: null;
}
if (typeof window === "undefined") {
return null;
}
try {
const localStorage = window.localStorage ?? null;
return typeof localStorage?.setItem === "function" ? localStorage : null;
} catch {
return null;
}
};
const normalizePinnedExtensions = (value) => {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
return value
.filter((item) => typeof item === "string" && item.trim().length > 0)
.map((item) => item.trim())
.filter((item) => {
if (seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
};
export const readPinnedExtensions = (storage) => {
const targetStorage = getStorageForRead(storage);
if (!targetStorage) {
return [];
}
try {
const raw = targetStorage.getItem(PINNED_EXTENSIONS_STORAGE_KEY);
return normalizePinnedExtensions(raw ? JSON.parse(raw) : []);
} catch {
return [];
}
};
export const writePinnedExtensions = (names, storage) => {
const targetStorage = getStorageForWrite(storage);
if (!targetStorage) {
return;
}
try {
targetStorage.setItem(
PINNED_EXTENSIONS_STORAGE_KEY,
JSON.stringify(normalizePinnedExtensions(names)),
);
} catch {
// Ignore restricted storage environments.
}
};

View File

@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
PINNED_EXTENSIONS_STORAGE_KEY,
readPinnedExtensions,
writePinnedExtensions,
} from '../src/views/extension/extensionPreferenceStorage.mjs';
test('readPinnedExtensions uses the legacy pinned extension storage key', () => {
assert.equal(PINNED_EXTENSIONS_STORAGE_KEY, 'astrbot.pinnedExtensions');
});
test('readPinnedExtensions parses stored pinned extension names', () => {
const storage = {
getItem(key) {
return key === PINNED_EXTENSIONS_STORAGE_KEY
? JSON.stringify(['alpha', 'beta', 'alpha', '', 1])
: null;
},
};
assert.deepEqual(readPinnedExtensions(storage), ['alpha', 'beta']);
});
test('readPinnedExtensions returns an empty array when storage access fails', () => {
const storage = {
getItem() {
throw new Error('SecurityError');
},
};
assert.deepEqual(readPinnedExtensions(storage), []);
});
test('writePinnedExtensions stores normalized pinned extension names', () => {
const writes = [];
const storage = {
setItem(key, value) {
writes.push([key, value]);
},
};
writePinnedExtensions(['alpha', 'beta', 'alpha', '', null], storage);
assert.deepEqual(writes, [
[PINNED_EXTENSIONS_STORAGE_KEY, JSON.stringify(['alpha', 'beta'])],
]);
});
test('writePinnedExtensions ignores unavailable storage', () => {
assert.doesNotThrow(() => writePinnedExtensions(['alpha'], null));
assert.doesNotThrow(() => writePinnedExtensions(['alpha'], {}));
});