-
-
-
-
+
-
-
-
- {{ tm('configSelection.normalConfig') }}
-
-
- {{ tm('configSelection.systemConfig') }}
-
-
-
+
+
+
+ {{ tm('messages.unsavedChangesNotice') }}
+
+
+
@@ -35,6 +45,7 @@
@@ -180,6 +191,10 @@
+
+
+
+
@@ -190,6 +205,12 @@ import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import StandaloneChat from '@/components/chat/StandaloneChat.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { useI18n, useModuleI18n } from '@/i18n/composables';
+import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
+import {
+ askForConfirmation as askForConfirmationDialog,
+ useConfirmDialog
+} from '@/utils/confirmDialog';
+import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
export default {
name: 'ConfigPage',
@@ -197,7 +218,8 @@ export default {
AstrBotCoreConfigWrapper,
VueMonacoEditor,
WaitingForRestart,
- StandaloneChat
+ StandaloneChat,
+ UnsavedChangesConfirmDialog
},
props: {
initialConfigId: {
@@ -208,13 +230,49 @@ export default {
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/config');
+ const confirmDialog = useConfirmDialog();
return {
t,
- tm
+ tm,
+ confirmDialog
};
},
+// 检查未保存的更改
+ async beforeRouteLeave(to, from, next) {
+ if (this.hasUnsavedChanges) {
+ const confirmed = await this.$refs.unsavedChangesDialog?.open({
+ title: this.tm('unsavedChangesWarning.dialogTitle'),
+ message: this.tm('unsavedChangesWarning.leavePage'),
+ confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
+ cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
+ closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
+ });
+ // 关闭弹窗不跳转
+ if (confirmed === 'close') {
+ next(false);
+ } else if (confirmed) {
+ const result = await this.updateConfig();
+ if (this.isSystemConfig) {
+ next(false);
+ } else {
+ if (result?.success) {
+ await new Promise(resolve => setTimeout(resolve, 800));
+ next();
+ } else {
+ next(false);
+ }
+ }
+ } else {
+ this.hasUnsavedChanges = false;
+ next();
+ }
+ } else {
+ next();
+ }
+ },
+
computed: {
messages() {
return {
@@ -225,6 +283,11 @@ export default {
configApplyError: this.tm('messages.configApplyError')
};
},
+ // 检查配置是否变化
+ configHasChanges() {
+ if (!this.originalConfigData || !this.config_data) return false;
+ return JSON.stringify(this.originalConfigData) !== JSON.stringify(this.config_data);
+ },
configInfoNameList() {
return this.configInfoList.map(info => info.name);
},
@@ -240,11 +303,28 @@ export default {
});
return items;
},
+ hasUnsavedChanges() {
+ if (!this.fetched) {
+ return false;
+ }
+ return this.getConfigSnapshot(this.config_data) !== this.lastSavedConfigSnapshot;
+ }
},
watch: {
config_data_str(val) {
this.config_data_has_changed = true;
},
+ config_data: {
+ deep: true,
+ handler() {
+ if (this.fetched) {
+ this.hasUnsavedChanges = this.configHasChanges;
+ }
+ }
+ },
+ async '$route.fullPath'(newVal) {
+ await this.syncConfigTypeFromHash(newVal);
+ },
initialConfigId(newVal) {
if (!newVal) {
return;
@@ -271,15 +351,18 @@ export default {
save_message: "",
save_message_success: "",
configContentKey: 0,
+ lastSavedConfigSnapshot: '',
// 配置类型切换
configType: 'normal', // 'normal' 或 'system'
+ configSearchKeyword: '',
// 系统配置开关
isSystemConfig: false,
// 多配置文件管理
selectedConfigID: null, // 用于存储当前选中的配置项信息
+ currentConfigId: null, // 跟踪当前正在编辑的配置id
configInfoList: [],
configFormData: {
name: '',
@@ -289,15 +372,72 @@ export default {
// 测试聊天
testChatDrawer: false,
testConfigId: null,
+
+ // 未保存的更改状态
+ hasUnsavedChanges: false,
+ // 存储原始配置
+ originalConfigData: null,
}
},
mounted() {
+ const hashConfigType = this.extractConfigTypeFromHash(
+ this.$route?.fullPath || ''
+ );
+ this.configType = hashConfigType || 'normal';
+ this.isSystemConfig = this.configType === 'system';
+
const targetConfigId = this.initialConfigId || 'default';
this.getConfigInfoList(targetConfigId);
// 初始化配置类型状态
this.configType = this.isSystemConfig ? 'system' : 'normal';
+
+ // 监听语言切换事件,重新加载配置以获取插件的 i18n 数据
+ window.addEventListener('astrbot-locale-changed', this.handleLocaleChange);
+
+ // 保存初始配置
+ this.$watch('config_data', (newVal) => {
+ if (!this.originalConfigData && newVal) {
+ this.originalConfigData = JSON.parse(JSON.stringify(newVal));
+ }
+ }, { immediate: false, deep: true });
+ },
+
+ beforeUnmount() {
+ // 移除语言切换事件监听器
+ window.removeEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
methods: {
+ // 处理语言切换事件,重新加载配置以获取插件的 i18n 数据
+ handleLocaleChange() {
+ // 重新加载当前配置
+ if (this.selectedConfigID) {
+ this.getConfig(this.selectedConfigID);
+ } else if (this.isSystemConfig) {
+ this.getConfig();
+ }
+ },
+
+ },
+ methods: {
+ extractConfigTypeFromHash(hash) {
+ const rawHash = String(hash || '');
+ const lastHashIndex = rawHash.lastIndexOf('#');
+ if (lastHashIndex === -1) {
+ return null;
+ }
+ const cleanHash = rawHash.slice(lastHashIndex + 1);
+ return cleanHash === 'system' || cleanHash === 'normal' ? cleanHash : null;
+ },
+ async syncConfigTypeFromHash(hash) {
+ const configType = this.extractConfigTypeFromHash(hash);
+ if (!configType || configType === this.configType) {
+ return false;
+ }
+
+ this.configType = configType;
+ await this.onConfigTypeToggle();
+ return true;
+ },
getConfigInfoList(abconf_id) {
// 获取配置列表
axios.get('/api/config/abconfs').then((res) => {
@@ -308,6 +448,7 @@ export default {
for (let i = 0; i < this.configInfoList.length; i++) {
if (this.configInfoList[i].id === abconf_id) {
this.selectedConfigID = this.configInfoList[i].id;
+ this.currentConfigId = this.configInfoList[i].id;
this.getConfig(abconf_id);
matched = true;
break;
@@ -317,6 +458,7 @@ export default {
if (!matched && this.configInfoList.length) {
// 当找不到目标配置时,默认展示列表中的第一个配置
this.selectedConfigID = this.configInfoList[0].id;
+ this.currentConfigId = this.configInfoList[0].id;
this.getConfig(this.selectedConfigID);
}
}
@@ -340,9 +482,18 @@ export default {
params: params
}).then((res) => {
this.config_data = res.data.data.config;
+ this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.fetched = true
this.metadata = res.data.data.metadata;
this.configContentKey += 1;
+ // 获取配置后更新
+ this.$nextTick(() => {
+ this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
+ this.hasUnsavedChanges = false;
+ if (!this.isSystemConfig) {
+ this.currentConfigId = abconf_id || this.selectedConfigID;
+ }
+ });
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
@@ -362,28 +513,37 @@ export default {
postData.conf_id = this.selectedConfigID;
}
- axios.post('/api/config/astrbot/update', postData).then((res) => {
+ return axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
+ this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
+ this.onConfigSaved();
if (this.isSystemConfig) {
- axios.post('/api/stat/restart-core').then(() => {
- this.$refs.wfr.check();
- })
+ restartAstrBotRuntime(this.$refs.wfr).catch(() => {})
}
+ return { success: true };
} else {
this.save_message = res.data.message || this.messages.saveError;
this.save_message_snack = true;
this.save_message_success = "error";
+ return { success: false };
}
}).catch((err) => {
this.save_message = this.messages.saveError;
this.save_message_snack = true;
this.save_message_success = "error";
+ return { success: false };
});
},
+ // 重置未保存状态
+ onConfigSaved() {
+ this.hasUnsavedChanges = false;
+ this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
+ },
+
configToString() {
this.config_data_str = JSON.stringify(this.config_data, null, 2);
this.config_data_has_changed = false;
@@ -423,15 +583,53 @@ export default {
this.save_message_success = "error";
});
},
- onConfigSelect(value) {
+ async onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
// 重置选择到之前的值
this.$nextTick(() => {
this.selectedConfigID = this.selectedConfigInfo.id || 'default';
+ this.getConfig(this.selectedConfigID);
});
} else {
- this.getConfig(value);
+ // 检查是否有未保存的更改
+ if (this.hasUnsavedChanges) {
+ // 获取之前正在编辑的配置id
+ const prevConfigId = this.isSystemConfig ? 'default' : (this.currentConfigId || this.selectedConfigID || 'default');
+ const message = this.tm('unsavedChangesWarning.switchConfig');
+ const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
+ title: this.tm('unsavedChangesWarning.dialogTitle'),
+ message: message,
+ confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
+ cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
+ closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
+ });
+ // 关闭弹窗不切换
+ if (saveAndSwitch === 'close') {
+ return;
+ }
+ if (saveAndSwitch) {
+ // 设置临时变量保存切换后的id
+ const currentSelectedId = this.selectedConfigID;
+ // 把id设置回切换前的用于保存上一次的配置,保存完后恢复id为切换后的
+ this.selectedConfigID = prevConfigId;
+ const result = await this.updateConfig();
+ this.selectedConfigID = currentSelectedId;
+ if (result?.success) {
+ this.selectedConfigID = value;
+ this.getConfig(value);
+ }
+ return;
+ } else {
+ // 取消保存并切换配置
+ this.selectedConfigID = value;
+ this.getConfig(value);
+ }
+ } else {
+ // 无未保存更改直接切换
+ this.selectedConfigID = value;
+ this.getConfig(value);
+ }
}
},
startCreateConfig() {
@@ -473,8 +671,9 @@ export default {
this.createNewConfig();
}
},
- confirmDeleteConfig(config) {
- if (confirm(this.tm('configManagement.confirmDelete').replace('{name}', config.name))) {
+ async confirmDeleteConfig(config) {
+ const message = this.tm('configManagement.confirmDelete').replace('{name}', config.name);
+ if (await askForConfirmationDialog(message, this.confirmDialog)) {
this.deleteConfig(config.id);
}
},
@@ -524,7 +723,34 @@ export default {
this.save_message_success = "error";
});
},
- onConfigTypeToggle() {
+ async onConfigTypeToggle() {
+ // 检查是否有未保存的更改
+ if (this.hasUnsavedChanges) {
+ const message = this.tm('unsavedChangesWarning.leavePage');
+ const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
+ title: this.tm('unsavedChangesWarning.dialogTitle'),
+ message: message,
+ confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
+ cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
+ closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
+ });
+ // 关闭弹窗
+ if (saveAndSwitch === 'close') {
+ // 恢复路由
+ const originalHash = this.isSystemConfig ? '#system' : '#normal';
+ this.$router.replace('/config' + originalHash);
+ this.configType = this.isSystemConfig ? 'system' : 'normal';
+ return;
+ }
+ if (saveAndSwitch) {
+ await this.updateConfig();
+ // 系统配置保存后不跳转
+ if (this.isSystemConfig) {
+ this.$router.replace('/config#system');
+ return;
+ }
+ }
+ }
this.isSystemConfig = this.configType === 'system';
this.fetched = false; // 重置加载状态
@@ -544,19 +770,7 @@ export default {
// 保持向后兼容性,更新 configType
this.configType = this.isSystemConfig ? 'system' : 'normal';
- this.fetched = false; // 重置加载状态
-
- if (this.isSystemConfig) {
- // 切换到系统配置
- this.getConfig();
- } else {
- // 切换回普通配置,如果有选中的配置文件则加载,否则加载default
- if (this.selectedConfigID) {
- this.getConfig(this.selectedConfigID);
- } else {
- this.getConfigInfoList("default");
- }
- }
+ this.onConfigTypeToggle();
},
openTestChat() {
if (!this.selectedConfigID) {
@@ -571,6 +785,9 @@ export default {
closeTestChat() {
this.testChatDrawer = false;
this.testConfigId = null;
+ },
+ getConfigSnapshot(config) {
+ return JSON.stringify(config ?? {});
}
},
}
@@ -582,6 +799,26 @@ export default {
text-transform: none !important;
}
+.unsaved-changes-banner {
+ border-radius: 8px;
+}
+
+.v-theme--light .unsaved-changes-banner {
+ background-color: #f1f4f9 !important;
+}
+
+.v-theme--dark .unsaved-changes-banner {
+ background-color: #2d2d2d !important;
+}
+
+.unsaved-changes-banner-wrap {
+ position: sticky;
+ top: calc(var(--v-layout-top, 64px));
+ z-index: 20;
+ width: 100%;
+ margin-bottom: 6px;
+}
+
/* 按钮切换样式优化 */
.v-btn-toggle .v-btn {
transition: all 0.3s ease !important;
@@ -629,6 +866,21 @@ export default {
.config-panel {
width: 100%;
}
+
+ .config-toolbar {
+ padding-right: 0 !important;
+ }
+
+ .config-toolbar-controls {
+ width: 100%;
+ flex-wrap: wrap;
+ }
+
+ .config-select,
+ .config-search-input {
+ width: 100%;
+ min-width: 0 !important;
+ }
}
/* 测试聊天抽屉样式 */
@@ -658,4 +910,4 @@ export default {
padding: 0;
border-radius: 0 0 16px 16px;
}
-
\ No newline at end of file
+
diff --git a/dashboard/src/views/ConversationPage.vue b/dashboard/src/views/ConversationPage.vue
index 2a615b294..3d25855f3 100644
--- a/dashboard/src/views/ConversationPage.vue
+++ b/dashboard/src/views/ConversationPage.vue
@@ -333,6 +333,10 @@ import { useCommonStore } from '@/stores/common';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import MessageList from '@/components/chat/MessageList.vue';
+import {
+ askForConfirmation as askForConfirmationDialog,
+ useConfirmDialog
+} from '@/utils/confirmDialog';
export default {
name: 'ConversationPage',
@@ -345,12 +349,14 @@ export default {
const { t, locale } = useI18n();
const { tm } = useModuleI18n('features/conversation');
const customizerStore = useCustomizerStore();
+ const confirmDialog = useConfirmDialog();
return {
t,
tm,
locale,
- customizerStore
+ customizerStore,
+ confirmDialog
};
},
@@ -744,9 +750,9 @@ export default {
},
// 关闭对话历史对话框
- closeHistoryDialog() {
+ async closeHistoryDialog() {
if (this.isEditingHistory) {
- if (confirm(this.tm('dialogs.view.confirmClose'))) {
+ if (await askForConfirmationDialog(this.tm('dialogs.view.confirmClose'), this.confirmDialog)) {
this.dialogView = false;
}
} else {
@@ -1115,7 +1121,7 @@ export default {
.text-truncate {
display: inline-block;
- max-width: 100px;
+ /* max-width: 100px; */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1133,4 +1139,4 @@ export default {
transform: translateY(0);
}
}
-
\ No newline at end of file
+
diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue
index 1bc72b508..4d0af43ec 100644
--- a/dashboard/src/views/CronJobPage.vue
+++ b/dashboard/src/views/CronJobPage.vue
@@ -55,11 +55,12 @@
{{ formatTime(item.last_run_at) }}
{{ item.note || tm('table.notAvailable') }}
-
+
- {{ tm('actions.delete')
- }}
+ class="mt-0" @change="toggleJob(item)" />
+
+ {{ tm('actions.delete') }}
+
diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue
index c62637e3c..026a08f39 100644
--- a/dashboard/src/views/ExtensionPage.vue
+++ b/dashboard/src/views/ExtensionPage.vue
@@ -1,5 +1,4 @@
@@ -1105,478 +159,16 @@ watch(activeTab, (newTab) => {
-
-
-
-
-
- mdi-puzzle
- {{ tm("tabs.installedPlugins") }}
-
-
- mdi-store
- {{ tm("tabs.market") }}
-
-
- mdi-server-network
- {{ tm("tabs.installedMcpServers") }}
-
-
- mdi-lightning-bolt
- {{ tm("tabs.skills") }}
-
-
- mdi-wrench
- {{ tm("tabs.handlersOperation") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- mdi-view-grid
-
-
- mdi-view-list
-
-
-
-
- {{
- showReserved ? "mdi-eye-off" : "mdi-eye"
- }}
- {{
- showReserved
- ? tm("buttons.hideSystemPlugins")
- : tm("buttons.showSystemPlugins")
- }}
-
-
-
- mdi-update
- {{ tm("buttons.updateAll") }}
-
-
-
- mdi-plus
- {{ tm("buttons.install") }}
-
-
-
-
-
-
- mdi-alert-circle
-
-
-
-
-
- mdi-alert-circle
- {{ tm("dialogs.error.title") }}
-
-
-
- {{ extension_data.message }}
-
-
- {{ tm("dialogs.error.checkConsole") }}
-
-
-
-
- {{ tm("buttons.close") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ tm("status.loading") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- item.display_name && item.display_name.length
- ? item.display_name
- : item.name
- }}
-
-
- {{ item.name }}
-
-
- {{ tm("status.system") }}
-
-
-
-
-
-
-
- {{ item.desc }}
-
-
-
-
-
- {{ item.version }}
- mdi-alert
-
- {{ tm("messages.hasUpdate") }}
- {{ item.online_version }}
-
-
-
-
-
- {{ item.author }}
-
-
-
-
- {{
- item.activated
- ? tm("status.enabled")
- : tm("status.disabled")
- }}
-
-
-
-
-
-
-
- mdi-play
- {{
- tm("tooltips.enable")
- }}
-
-
- mdi-pause
- {{
- tm("tooltips.disable")
- }}
-
-
-
- mdi-refresh
- {{
- tm("tooltips.reload")
- }}
-
-
-
- mdi-cog
- {{
- tm("tooltips.configure")
- }}
-
-
-
- mdi-information
- {{
- tm("tooltips.viewInfo")
- }}
-
-
-
- mdi-book-open-page-variant
- {{
- tm("tooltips.viewDocs")
- }}
-
-
-
- mdi-update
- {{
- tm("tooltips.update")
- }}
-
-
-
- mdi-delete
- {{
- tm("tooltips.uninstall")
- }}
-
-
-
-
-
-
-
-
mdi-puzzle-outline
-
- {{ tm("empty.noPlugins") }}
-
-
- {{ tm("empty.noPluginsDesc") }}
-
-
-
-
-
-
-
-
-
-
-
- mdi-puzzle-outline
- {{ tm("empty.noPlugins") }}
-
- {{ tm("empty.noPluginsDesc") }}
-
-
-
-
-
-
- uninstallExtension(ext.name, options)
- "
- @update="updateExtension(extension.name)"
- @reload="reloadPlugin(extension.name)"
- @toggle-activation="
- extension.activated
- ? pluginOff(extension)
- : pluginOn(extension)
- "
- @view-handlers="showPluginInfo(extension)"
- @view-readme="viewReadme(extension)"
- @view-changelog="viewChangelog(extension)"
- >
-
-
-
-
-
-
+
+
+
+
{{ tm("tabs.handlersOperation") }}
+
+
{
+
+
+
{{ tm("tabs.installedMcpServers") }}
+
+
{
+
+
+
{{ tm("tabs.skills") }}
+
+
{
-
-
-
-
-
-
- mdi-source-branch
-
- {{ tm("market.source") }}
-
-
-
-
-
-
- {{
- selectedSource
- ? customSources.find(
- (s) => s.url === selectedSource,
- )?.name
- : tm("market.defaultSource")
- }}
-
- mdi-chevron-down
- {{
- selectedSource || tm("market.defaultOfficialSource")
- }}
-
-
-
-
- {{ tm("market.availableSources") }}
-
-
-
-
-
- {{
- tm("market.defaultSource")
- }}
-
-
-
-
-
-
-
-
- {{ source.name }}
- {{
- source.url
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ tm("market.allPlugins") }}({{
- filteredMarketPlugins.length
- }})
-
-
- mdi-refresh
-
-
-
-
-
-
-
-
-
- mdi-sort
-
-
-
-
-
- {{
- sortOrder === "desc"
- ? "mdi-sort-descending"
- : "mdi-sort-ascending"
- }}
-
- {{
- sortOrder === "desc"
- ? tm("sort.descending")
- : tm("sort.ascending")
- }}
-
-
-
-
-
-
-
-
-
-
-
- 🥳 推荐
-
-
-
-
-
-
-
-
-
-
-
- {{
- plugin.display_name?.length
- ? plugin.display_name
- : showPluginFullName
- ? plugin.name
- : plugin.trimmedName
- }}
-
-
-
-
-
-
-
-
- {{ plugin.desc }}
-
-
-
-
-
-
- {{ plugin.stars }}
-
-
-
- {{
- new Date(plugin.updated_at).toLocaleString()
- }}
-
-
-
-
-
-
-
-
- {{ tag === "danger" ? tm("tags.danger") : tag }}
-
-
-
-
- +{{ plugin.tags.length - 2 }}
-
-
-
-
-
- {{ tag === "danger" ? tm("tags.danger") : tag }}
-
-
-
-
-
-
-
- {{ tm("buttons.viewRepo") }}
-
-
- {{ tm("buttons.install") }}
-
-
- ✓ {{ tm("status.installed") }}
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -2465,6 +509,31 @@ watch(activeTab, (newTab) => {
+
+
+
+
+ mdi-alert
+ {{ tm("dialogs.versionCompatibility.title") }}
+
+
+ {{ tm("dialogs.versionCompatibility.message") }}
+
+ {{ versionCompatibilityDialog.message }}
+
+
+
+
+
+ {{ tm("dialogs.versionCompatibility.cancel") }}
+
+
+ {{ tm("dialogs.versionCompatibility.confirm") }}
+
+
+
+
+
{
placeholder="https://github.com/username/repo"
>
+
+
+ {{ tm("card.status.astrbotVersion") }}:
+ {{ selectedInstallPlugin.astrbot_version }}
+
+
+ {{ tm("card.status.supportPlatform") }}:
+ {{
+ getPlatformDisplayList(selectedInstallPlugin.support_platforms).join(
+ ", ",
+ )
+ }}
+
+
+ {{ installCompat.message }}
+
+
+
@@ -2564,6 +673,95 @@ watch(activeTab, (newTab) => {
+
+
+
+ {{
+ tm("market.sourceManagement")
+ }}
+
+
+
+
+
{{ tm("market.availableSources") }}
+
+ {{ tm("market.addSource") }}
+
+
+
+
+
+
+
+
+ {{ tm("market.defaultSource") }}
+
+
+
+
+
+
+ {{ source.name }}
+ {{
+ source.url
+ }}
+
+
+
+
+
+
+
+
+
+ {{
+ tm("buttons.close")
+ }}
+
+
+
+
@@ -2668,38 +866,6 @@ watch(activeTab, (newTab) => {
background-color: #f5f5f5;
}
-.plugin-description {
- color: rgba(var(--v-theme-on-surface), 0.6);
- line-height: 1.3;
- margin-bottom: 6px;
- flex: 1;
- overflow-y: hidden;
-}
-
-.plugin-card:hover .plugin-description {
- overflow-y: auto;
-}
-
-.plugin-description::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-.plugin-description::-webkit-scrollbar-track {
- background: transparent;
-}
-
-.plugin-description::-webkit-scrollbar-thumb {
- background-color: rgba(var(--v-theme-primary-rgb), 0.4);
- border-radius: 4px;
- border: 2px solid transparent;
- background-clip: content-box;
-}
-
-.plugin-description::-webkit-scrollbar-thumb:hover {
- background-color: rgba(var(--v-theme-primary-rgb), 0.6);
-}
-
.fab-button {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
diff --git a/dashboard/src/views/PlatformPage.vue b/dashboard/src/views/PlatformPage.vue
index 2860b6f0b..f50df9554 100644
--- a/dashboard/src/views/PlatformPage.vue
+++ b/dashboard/src/views/PlatformPage.vue
@@ -195,8 +195,12 @@ import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCard from '@/components/shared/ItemCard.vue';
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
import { useCommonStore } from '@/stores/common';
-import { useI18n, useModuleI18n } from '@/i18n/composables';
+import { useI18n, useModuleI18n, mergeDynamicTranslations } from '@/i18n/composables';
import { getPlatformIcon, getTutorialLink } from '@/utils/platformUtils';
+import {
+ askForConfirmation as askForConfirmationDialog,
+ useConfirmDialog
+} from '@/utils/confirmDialog';
export default {
name: 'PlatformPage',
@@ -210,10 +214,12 @@ export default {
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/platform');
+ const confirmDialog = useConfirmDialog();
return {
t,
- tm
+ tm,
+ confirmDialog
};
},
data() {
@@ -274,15 +280,25 @@ export default {
this.statsRefreshInterval = setInterval(() => {
this.getPlatformStats();
}, 10000);
+
+ // 监听语言切换事件,重新加载配置以获取插件的 i18n 数据
+ window.addEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
beforeUnmount() {
if (this.statsRefreshInterval) {
clearInterval(this.statsRefreshInterval);
}
+ // 移除语言切换事件监听器
+ window.removeEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
methods: {
+ // 处理语言切换事件,重新加载配置以获取插件的 i18n 数据
+ handleLocaleChange() {
+ this.getConfig();
+ },
+
// 从工具函数导入
getPlatformIcon(platform_id) {
// 首先检查是否有来自插件的 logo_token
@@ -299,6 +315,12 @@ export default {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
+
+ // 将插件平台适配器的 i18n 翻译注入到前端 i18n 系统中
+ const platformI18n = res.data.data.platform_i18n_translations;
+ if (platformI18n && typeof platformI18n === 'object') {
+ mergeDynamicTranslations('features.config-metadata', platformI18n);
+ }
}).catch((err) => {
this.showError(err);
});
@@ -351,8 +373,99 @@ export default {
}
},
+ findPlatformTemplate(platform) {
+ const templates = this.metadata?.platform_group?.metadata?.platform?.config_template || {};
+
+ if (platform?.type && templates[platform.type]) {
+ return templates[platform.type];
+ }
+ if (platform?.id && templates[platform.id]) {
+ return templates[platform.id];
+ }
+
+ for (const template of Object.values(templates)) {
+ if (template?.type === platform?.type) {
+ return template;
+ }
+ }
+ return null;
+ },
+
+ mergeConfigWithTemplate(sourceConfig, templateConfig) {
+ const merge = (source, reference) => {
+ const target = {};
+ const sourceObj = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
+ const referenceObj = reference && typeof reference === 'object' && !Array.isArray(reference) ? reference : null;
+
+ if (!referenceObj) {
+ for (const [key, value] of Object.entries(sourceObj)) {
+ if (Array.isArray(value)) {
+ target[key] = [...value];
+ } else if (value && typeof value === 'object') {
+ target[key] = { ...value };
+ } else {
+ target[key] = value;
+ }
+ }
+ return target;
+ }
+
+ // 1) 先按模板顺序写入,保证字段相对顺序与 template 一致
+ for (const [key, refValue] of Object.entries(referenceObj)) {
+ const hasSourceKey = Object.prototype.hasOwnProperty.call(sourceObj, key);
+ const sourceValue = sourceObj[key];
+
+ if (refValue && typeof refValue === 'object' && !Array.isArray(refValue)) {
+ target[key] = merge(
+ hasSourceKey && sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
+ ? sourceValue
+ : {},
+ refValue
+ );
+ continue;
+ }
+
+ if (hasSourceKey) {
+ if (Array.isArray(sourceValue)) {
+ target[key] = [...sourceValue];
+ } else if (sourceValue && typeof sourceValue === 'object') {
+ target[key] = { ...sourceValue };
+ } else {
+ target[key] = sourceValue;
+ }
+ } else if (Array.isArray(refValue)) {
+ target[key] = [...refValue];
+ } else {
+ target[key] = refValue;
+ }
+ }
+
+ // 2) 再补充 source 中模板没有的额外字段,保持旧配置兼容性
+ for (const [key, value] of Object.entries(sourceObj)) {
+ if (Object.prototype.hasOwnProperty.call(referenceObj, key)) {
+ continue;
+ }
+ if (Array.isArray(value)) {
+ target[key] = [...value];
+ } else if (value && typeof value === 'object') {
+ target[key] = { ...value };
+ } else {
+ target[key] = value;
+ }
+ }
+
+ return target;
+ };
+
+ return merge(sourceConfig, templateConfig);
+ },
+
editPlatform(platform) {
- this.updatingPlatformConfig = JSON.parse(JSON.stringify(platform));
+ const platformCopy = JSON.parse(JSON.stringify(platform));
+ const template = this.findPlatformTemplate(platformCopy);
+ this.updatingPlatformConfig = template
+ ? this.mergeConfigWithTemplate(platformCopy, template)
+ : platformCopy;
this.updatingMode = true;
this.showAddPlatformDialog = true;
this.$nextTick(() => {
@@ -360,15 +473,18 @@ export default {
});
},
- deletePlatform(platform) {
- if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
- axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
- this.getConfig();
- this.showSuccess(res.data.message || this.messages.deleteSuccess);
- }).catch((err) => {
- this.showError(err.response?.data?.message || err.message);
- });
+ async deletePlatform(platform) {
+ const message = `${this.messages.deleteConfirm} ${platform.id}?`;
+ if (!(await askForConfirmationDialog(message, this.confirmDialog))) {
+ return;
}
+
+ axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
+ this.getConfig();
+ this.showSuccess(res.data.message || this.messages.deleteSuccess);
+ }).catch((err) => {
+ this.showError(err.response?.data?.message || err.message);
+ });
},
platformStatusChange(platform) {
diff --git a/dashboard/src/views/SessionManagementPage.vue b/dashboard/src/views/SessionManagementPage.vue
index b754f8c1c..5008e1dd3 100644
--- a/dashboard/src/views/SessionManagementPage.vue
+++ b/dashboard/src/views/SessionManagementPage.vue
@@ -522,16 +522,22 @@
diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue
index 892b628b6..029cc5a82 100644
--- a/dashboard/src/views/SubAgentPage.vue
+++ b/dashboard/src/views/SubAgentPage.vue
@@ -1,166 +1,248 @@
-
+
-
+
{{ tm('page.title') }}
- {{ tm('page.beta') }}
+
+ {{ tm('page.beta') }}
+
{{ tm('page.subtitle') }}
-
-
{{ tm('actions.refresh') }}
-
{{ tm('actions.save') }}
+
+
+ {{ tm('actions.refresh') }}
+
+
+ {{ tm('actions.save') }}
+
-
+
+
-
+
+
+
{{ tm('section.globalSettings') || 'Global Settings' }}
+
+ {{ mainStateDescription }}
+
+
+
+
+
+
+
+ >
+
+
+ {{ tm('switches.enable') }}
+ {{ tm('switches.enableHint') }}
+
+
+
+ >
+
+
+ {{ tm('switches.dedupe') }}
+ {{ tm('switches.dedupeHint') }}
+
+
+
+
+
-
- {{ mainStateDescription }}
-
+
+
+
+
+
{{ tm('section.title') }}
+
+ {{ cfg.agents.length }}
+
+
+
+ {{ tm('actions.add') }}
+
+
-
-
{{ tm('section.title') }}
-
- {{ tm('actions.add') }}
-
-
+
+
+
+
+
+
-
-
-
-
-
-
- {{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }}
-
-
-
-
{{ agent.name || tm('cards.unnamed') }}
-
- {{ tm('cards.transferPrefix', { name: agent.name || '...' }) }}
+
+
+
+
+ {{ agent.name || tm('cards.unnamed') }}
+
+
+
+ {{ agent.public_description || tm('cards.noDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ tm('form.providerLabel') }}
+
+
-
+
-
-
- {{ tm('cards.switchLabel') }}
-
-
-
- {{ tm('actions.delete') }}
-
+
+
{{ tm('form.personaLabel') }}
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ tm('cards.previewTitle') }}
-
-
- {{ tm('cards.transferPrefix', { name: agent.name || '...' }) }}
-
-
- {{ tm('cards.personaChip', { id: agent.persona_id }) }}
-
+
+
+
+
+
+
+ {{ tm('cards.personaPreview') }}
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
{{ tm('empty.title') }}
+
{{ tm('empty.subtitle') }}
+
+ {{ tm('empty.action') }}
+
+
-
+
{{ snackbar.message }}
+
+ {{ tm('actions.close') }}
+
@@ -169,9 +251,12 @@
import { computed, onMounted, ref } from 'vue'
import axios from 'axios'
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
+import PersonaSelector from '@/components/shared/PersonaSelector.vue'
+import PersonaQuickPreview from '@/components/shared/PersonaQuickPreview.vue'
import { useModuleI18n } from '@/i18n/composables'
type SubAgentItem = {
+
__key: string
name: string
persona_id: string
@@ -207,9 +292,6 @@ const cfg = ref
({
agents: []
})
-const personaOptions = ref<{ title: string; value: string }[]>([])
-const personaLoading = ref(false)
-
const mainStateDescription = computed(() =>
cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled')
)
@@ -255,24 +337,6 @@ async function loadConfig() {
}
}
-async function loadPersonas() {
- personaLoading.value = true
- try {
- const res = await axios.get('/api/persona/list')
- if (res.data.status === 'ok') {
- const list = Array.isArray(res.data.data) ? res.data.data : []
- personaOptions.value = list.map((p: any) => ({
- title: p.persona_id,
- value: p.persona_id
- }))
- }
- } catch (e: any) {
- toast(e?.response?.data?.message || tm('messages.loadPersonaFailed'), 'error')
- } finally {
- personaLoading.value = false
- }
-}
-
function addAgent() {
cfg.value.agents.push({
__key: `${Date.now()}_${Math.random().toString(16).slice(2)}`,
@@ -344,7 +408,7 @@ async function save() {
}
async function reload() {
- await Promise.all([loadConfig(), loadPersonas()])
+ await Promise.all([loadConfig()])
}
onMounted(() => {
@@ -354,101 +418,21 @@ onMounted(() => {
-
-
diff --git a/dashboard/src/views/WelcomePage.vue b/dashboard/src/views/WelcomePage.vue
new file mode 100644
index 000000000..5cabdc66a
--- /dev/null
+++ b/dashboard/src/views/WelcomePage.vue
@@ -0,0 +1,433 @@
+
+
+
+
+
+
+ {{ greetingText }} {{ greetingEmoji }}
+
+
+ {{ tm('subtitle') }}
+
+
+
+
+
+
+
+
+ {{ tm('onboard.title') }}
+
+
+
+
+
+
{{ tm('onboard.step1Title') }}
+
{{ tm('onboard.step1Desc') }}
+
+
+ {{ tm('onboard.configure') }}
+
+
+ {{ tm('onboard.completed') }}
+
+
+
+
+
+
+
+
{{ tm('onboard.step2Title')
+ }}
+
+
{{ tm('onboard.step2Desc') }}
+
+
+ {{ tm('onboard.configure') }}
+
+
+ {{ tm('onboard.completed') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm('resources.title') }}
+
+
+
+
+
+
+ mdi-github
+ GitHub
+
+
+ {{ tm('resources.githubDesc') }}
+
+
+
+
+
+
+
+
+ mdi-book-open-variant
+ {{ tm('resources.docsTitle') }}
+
+
+ {{ tm('resources.docsDesc') }}
+
+
+
+
+
+
+
+
+ mdi-hand-heart
+ {{ tm('resources.afdianTitle') }}
+
+
+ {{ tm('resources.afdianDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm('announcement.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/views/authentication/auth/LoginPage.vue b/dashboard/src/views/authentication/auth/LoginPage.vue
index b659eae27..c647dc8e5 100644
--- a/dashboard/src/views/authentication/auth/LoginPage.vue
+++ b/dashboard/src/views/authentication/auth/LoginPage.vue
@@ -1,56 +1,23 @@
+
+
+
+
+
+
{{ tm("titles.installedAstrBotPlugins") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ showReserved ? "mdi-eye-off" : "mdi-eye"
+ }}
+ {{
+ showReserved
+ ? tm("buttons.hideSystemPlugins")
+ : tm("buttons.showSystemPlugins")
+ }}
+
+
+
+ mdi-update
+ {{ tm("buttons.updateAll") }}
+
+
+
+
+
+ mdi-alert-circle
+
+
+
+
+
+ mdi-alert-circle
+ {{ tm("dialogs.error.title") }}
+
+
+
+ {{ extension_data.message }}
+
+
+ {{ tm("dialogs.error.checkConsole") }}
+
+
+
+
+ 尝试一键重载修复
+
+
+ {{ tm("buttons.close") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm("status.loading") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ item.display_name && item.display_name.length
+ ? item.display_name
+ : item.name
+ }}
+
+
+ {{ item.name }}
+
+
+ {{ tm("status.system") }}
+
+
+
+
+
+
+
+
+ {{ item.desc }}
+
+
+
+ {{ tm("card.status.supportPlatform") }}:
+
+
+ {{ platformId }}
+
+
+
+
+ {{ tm("card.status.astrbotVersion") }}:
+
+
+ {{ item.astrbot_version }}
+
+
+
+
+
+
+
+ {{ item.version }}
+ mdi-alert
+
+ {{ tm("messages.hasUpdate") }}
+ {{ item.online_version }}
+
+
+
+
+
+ {{ item.author }}
+
+
+
+
+
+ {{ tm("buttons.enable") }}
+
+
+ {{ tm("buttons.disable") }}
+
+
+
+ {{ tm("buttons.reload") }}
+
+
+
+ {{ tm("buttons.configure") }}
+
+
+
+ {{ tm("buttons.viewDocs") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
mdi-puzzle-outline
+
+ {{ tm("empty.noPlugins") }}
+
+
+ {{ tm("empty.noPluginsDesc") }}
+
+
+
+
+
+
+
+
+
+
+
+ mdi-puzzle-outline
+ {{ tm("empty.noPlugins") }}
+
+ {{ tm("empty.noPluginsDesc") }}
+
+
+
+
+
+
+ uninstallExtension(ext.name, options)
+ "
+ @update="updateExtension(extension.name)"
+ @reload="reloadPlugin(extension.name)"
+ @toggle-activation="
+ extension.activated
+ ? pluginOff(extension)
+ : pluginOn(extension)
+ "
+ @view-handlers="showPluginInfo(extension)"
+ @view-readme="viewReadme(extension)"
+ @view-changelog="viewChangelog(extension)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/views/extension/MarketPluginsTab.vue b/dashboard/src/views/extension/MarketPluginsTab.vue
new file mode 100644
index 000000000..63cc6b957
--- /dev/null
+++ b/dashboard/src/views/extension/MarketPluginsTab.vue
@@ -0,0 +1,373 @@
+
+
+
+
+
+
+
{{ tm("tabs.market") }}
+
+
+
+
+ mdi-source-branch
+
+ {{ currentSourceName }}
+
+
+
+
+
+
+
+
+
+
+ mdi-alert-outline
+ {{ tm("market.sourceSafetyWarning") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm("market.randomPlugins") }}
+
+
+ {{ tm("buttons.reshuffle") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm("market.allPlugins") }}({{
+ filteredMarketPlugins.length
+ }})
+
+
+ mdi-refresh
+
+
+
+
+
+
+ mdi-sort
+
+
+
+
+ {{
+ sortOrder === "desc"
+ ? "mdi-sort-descending"
+ : "mdi-sort-ascending"
+ }}
+
+ {{
+ sortOrder === "desc"
+ ? tm("sort.descending")
+ : tm("sort.ascending")
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js
new file mode 100644
index 000000000..93b716e2a
--- /dev/null
+++ b/dashboard/src/views/extension/useExtensionPage.js
@@ -0,0 +1,1466 @@
+import axios from "axios";
+import { pinyin } from "pinyin-pro";
+import { useCommonStore } from "@/stores/common";
+import { useI18n, useModuleI18n } from "@/i18n/composables";
+import defaultPluginIcon from "@/assets/images/plugin_icon.png";
+import { getPlatformDisplayName } from "@/utils/platformUtils";
+import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
+import { useRoute, useRouter } from "vue-router";
+
+export const useExtensionPage = () => {
+
+
+ const commonStore = useCommonStore();
+ const { t } = useI18n();
+ const { tm } = useModuleI18n("features/extension");
+ const router = useRouter();
+ const route = useRoute();
+
+ const getSelectedGitHubProxy = () => {
+ if (typeof window === "undefined" || !window.localStorage) return "";
+ return localStorage.getItem("githubProxyRadioValue") === "1"
+ ? localStorage.getItem("selectedGitHubProxy") || ""
+ : "";
+ };
+
+ // 检查指令冲突并提示
+ const conflictDialog = reactive({
+ show: false,
+ count: 0,
+ });
+ const checkAndPromptConflicts = async () => {
+ try {
+ const res = await axios.get("/api/commands");
+ if (res.data.status === "ok") {
+ const conflicts = res.data.data.summary?.conflicts || 0;
+ if (conflicts > 0) {
+ conflictDialog.count = conflicts;
+ conflictDialog.show = true;
+ }
+ }
+ } catch (err) {
+ console.debug("Failed to check command conflicts:", err);
+ }
+ };
+ const handleConflictConfirm = () => {
+ activeTab.value = "commands";
+ };
+
+ const fileInput = ref(null);
+ const activeTab = ref("installed");
+ const validTabs = ["installed", "market", "mcp", "skills", "components"];
+ const isValidTab = (tab) => validTabs.includes(tab);
+ const getLocationHash = () =>
+ typeof window !== "undefined" ? window.location.hash : "";
+ const extractTabFromHash = (hash) => {
+ const lastHashIndex = (hash || "").lastIndexOf("#");
+ if (lastHashIndex === -1) return "";
+ return hash.slice(lastHashIndex + 1);
+ };
+ const syncTabFromHash = (hash) => {
+ const tab = extractTabFromHash(hash);
+ if (isValidTab(tab)) {
+ activeTab.value = tab;
+ return true;
+ }
+ return false;
+ };
+ const extension_data = reactive({
+ data: [],
+ message: "",
+ });
+
+ // 从 localStorage 恢复显示系统插件的状态,默认为 false(隐藏)
+ const getInitialShowReserved = () => {
+ if (typeof window !== "undefined" && window.localStorage) {
+ const saved = localStorage.getItem("showReservedPlugins");
+ return saved === "true";
+ }
+ return false;
+ };
+ const showReserved = ref(getInitialShowReserved());
+ const snack_message = ref("");
+ const snack_show = ref(false);
+ const snack_success = ref("success");
+ const configDialog = ref(false);
+ const extension_config = reactive({
+ metadata: {},
+ config: {},
+ });
+ const pluginMarketData = ref([]);
+ const loadingDialog = reactive({
+ show: false,
+ title: "",
+ statusCode: 0, // 0: loading, 1: success, 2: error,
+ result: "",
+ });
+ const showPluginInfoDialog = ref(false);
+ const selectedPlugin = ref({});
+ const curr_namespace = ref("");
+ const updatingAll = ref(false);
+
+ const readmeDialog = reactive({
+ show: false,
+ pluginName: "",
+ repoUrl: null,
+ });
+
+ // 强制更新确认对话框
+ const forceUpdateDialog = reactive({
+ show: false,
+ extensionName: "",
+ });
+
+ // 更新全部插件确认对话框
+ const updateAllConfirmDialog = reactive({
+ show: false,
+ });
+
+ // 插件更新日志对话框(复用 ReadmeDialog)
+ const changelogDialog = reactive({
+ show: false,
+ pluginName: "",
+ repoUrl: null,
+ });
+
+ // 新增变量支持列表视图
+ // 从 localStorage 恢复显示模式,默认为 false(卡片视图)
+ const getInitialListViewMode = () => {
+ if (typeof window !== "undefined" && window.localStorage) {
+ return localStorage.getItem("pluginListViewMode") === "true";
+ }
+ return false;
+ };
+ const isListView = ref(getInitialListViewMode());
+ const pluginSearch = ref("");
+ const loading_ = ref(false);
+
+ // 分页相关
+ const currentPage = ref(1);
+
+ // 危险插件确认对话框
+ const dangerConfirmDialog = ref(false);
+ const selectedDangerPlugin = ref(null);
+ const selectedMarketInstallPlugin = ref(null);
+ const installCompat = reactive({
+ checked: false,
+ compatible: true,
+ message: "",
+ });
+
+ // AstrBot 版本范围不兼容警告对话框
+ const versionCompatibilityDialog = reactive({
+ show: false,
+ message: "",
+ });
+
+ // 卸载插件确认对话框(列表模式用)
+ const showUninstallDialog = ref(false);
+ const pluginToUninstall = ref(null);
+
+ // 自定义插件源相关
+ const showSourceDialog = ref(false);
+ const showSourceManagerDialog = ref(false);
+ const sourceName = ref("");
+ const sourceUrl = ref("");
+ const customSources = ref([]);
+ const selectedSource = ref(null);
+ const showRemoveSourceDialog = ref(false);
+ const sourceToRemove = ref(null);
+ const editingSource = ref(false);
+ const originalSourceUrl = ref("");
+
+ // 插件市场相关
+ const extension_url = ref("");
+ const dialog = ref(false);
+ const upload_file = ref(null);
+ const uploadTab = ref("file");
+ const showPluginFullName = ref(false);
+ const marketSearch = ref("");
+ const debouncedMarketSearch = ref("");
+ const refreshingMarket = ref(false);
+ const sortBy = ref("default"); // default, stars, author, updated
+ const sortOrder = ref("desc"); // desc (降序) or asc (升序)
+ const randomPluginNames = ref([]);
+
+ // 插件市场拼音搜索
+ const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
+ const toPinyinText = (s) =>
+ pinyin(s ?? "", { toneType: "none" })
+ .toLowerCase()
+ .replace(/\s+/g, "");
+ const toInitials = (s) =>
+ pinyin(s ?? "", { pattern: "first", toneType: "none" })
+ .toLowerCase()
+ .replace(/\s+/g, "");
+ const marketCustomFilter = (value, query, item) => {
+ const q = normalizeStr(query);
+ if (!q) return true;
+
+ const candidates = new Set();
+ if (value != null) candidates.add(String(value));
+ if (item?.name) candidates.add(String(item.name));
+ if (item?.trimmedName) candidates.add(String(item.trimmedName));
+ if (item?.display_name) candidates.add(String(item.display_name));
+ if (item?.desc) candidates.add(String(item.desc));
+ if (item?.author) candidates.add(String(item.author));
+
+ for (const v of candidates) {
+ const nv = normalizeStr(v);
+ if (nv.includes(q)) return true;
+ const pv = toPinyinText(v);
+ if (pv.includes(q)) return true;
+ const iv = toInitials(v);
+ if (iv.includes(q)) return true;
+ }
+ return false;
+ };
+
+ const plugin_handler_info_headers = computed(() => [
+ { title: tm("table.headers.eventType"), key: "event_type_h" },
+ { title: tm("table.headers.description"), key: "desc", maxWidth: "250px" },
+ { title: tm("table.headers.specificType"), key: "type" },
+ { title: tm("table.headers.trigger"), key: "cmd" },
+ ]);
+
+ // 插件表格的表头定义
+ const pluginHeaders = computed(() => [
+ { title: tm("table.headers.name"), key: "name", width: "200px" },
+ { title: tm("table.headers.description"), key: "desc", width: "180px" },
+ { title: tm("table.headers.version"), key: "version", width: "100px" },
+ { title: tm("table.headers.author"), key: "author", width: "100px" },
+ {
+ title: tm("table.headers.actions"),
+ key: "actions",
+ sortable: false,
+ width: "520px",
+ },
+ ]);
+
+ // 过滤要显示的插件
+ const filteredExtensions = computed(() => {
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ if (!showReserved.value) {
+ return data.filter((ext) => !ext.reserved);
+ }
+ return data;
+ });
+
+ // 通过搜索过滤插件
+ const filteredPlugins = computed(() => {
+ if (!pluginSearch.value) {
+ return filteredExtensions.value;
+ }
+
+ const search = pluginSearch.value.toLowerCase();
+ return filteredExtensions.value.filter((plugin) => {
+ const supportPlatforms = Array.isArray(plugin.support_platforms)
+ ? plugin.support_platforms.join(" ").toLowerCase()
+ : "";
+ const astrbotVersion = (plugin.astrbot_version ?? "").toLowerCase();
+ return (
+ plugin.name?.toLowerCase().includes(search) ||
+ plugin.desc?.toLowerCase().includes(search) ||
+ plugin.author?.toLowerCase().includes(search) ||
+ supportPlatforms.includes(search) ||
+ astrbotVersion.includes(search)
+ );
+ });
+ });
+
+ // 过滤后的插件市场数据(带搜索)
+ const filteredMarketPlugins = computed(() => {
+ if (!debouncedMarketSearch.value) {
+ return pluginMarketData.value;
+ }
+
+ const search = debouncedMarketSearch.value.toLowerCase();
+ return pluginMarketData.value.filter((plugin) => {
+ // 使用自定义过滤器
+ return (
+ marketCustomFilter(plugin.name, search, plugin) ||
+ marketCustomFilter(plugin.desc, search, plugin) ||
+ marketCustomFilter(plugin.author, search, plugin)
+ );
+ });
+ });
+
+ // 所有插件列表,推荐插件排在前面
+ const sortedPlugins = computed(() => {
+ let plugins = [...filteredMarketPlugins.value];
+
+ // 根据排序选项排序
+ if (sortBy.value === "stars") {
+ // 按 star 数排序
+ plugins.sort((a, b) => {
+ const starsA = a.stars ?? 0;
+ const starsB = b.stars ?? 0;
+ return sortOrder.value === "desc" ? starsB - starsA : starsA - starsB;
+ });
+ } else if (sortBy.value === "author") {
+ // 按作者名字典序排序
+ plugins.sort((a, b) => {
+ const authorA = (a.author ?? "").toLowerCase();
+ const authorB = (b.author ?? "").toLowerCase();
+ const result = authorA.localeCompare(authorB);
+ return sortOrder.value === "desc" ? -result : result;
+ });
+ } else if (sortBy.value === "updated") {
+ // 按更新时间排序
+ plugins.sort((a, b) => {
+ const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
+ const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
+ return sortOrder.value === "desc" ? dateB - dateA : dateA - dateB;
+ });
+ } else {
+ // default: 推荐插件排在前面
+ const pinned = plugins.filter((plugin) => plugin?.pinned);
+ const notPinned = plugins.filter((plugin) => !plugin?.pinned);
+ return [...pinned, ...notPinned];
+ }
+
+ return plugins;
+ });
+
+ const RANDOM_PLUGINS_COUNT = 3;
+
+ const randomPlugins = computed(() => {
+ const allPlugins = pluginMarketData.value;
+ if (allPlugins.length === 0) return [];
+
+ const pluginsByName = new Map(allPlugins.map((plugin) => [plugin.name, plugin]));
+ const selected = randomPluginNames.value
+ .map((name) => pluginsByName.get(name))
+ .filter(Boolean);
+
+ if (selected.length > 0) {
+ return selected;
+ }
+
+ return allPlugins.slice(0, Math.min(RANDOM_PLUGINS_COUNT, allPlugins.length));
+ });
+
+ const shufflePlugins = (plugins) => {
+ const shuffled = [...plugins];
+ for (let i = shuffled.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+ }
+ return shuffled;
+ };
+
+ const refreshRandomPlugins = () => {
+ const shuffled = shufflePlugins(pluginMarketData.value);
+ randomPluginNames.value = shuffled
+ .slice(0, Math.min(RANDOM_PLUGINS_COUNT, shuffled.length))
+ .map((plugin) => plugin.name);
+ };
+
+ // 分页计算属性
+ const displayItemsPerPage = 9; // 固定每页显示9个卡片(3行)
+
+ const totalPages = computed(() => {
+ return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
+ });
+
+ const paginatedPlugins = computed(() => {
+ const start = (currentPage.value - 1) * displayItemsPerPage;
+ const end = start + displayItemsPerPage;
+ return sortedPlugins.value.slice(start, end);
+ });
+
+ const updatableExtensions = computed(() => {
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ return data.filter((ext) => ext.has_update);
+ });
+
+ // 方法
+ const toggleShowReserved = () => {
+ showReserved.value = !showReserved.value;
+ // 保存到 localStorage
+ if (typeof window !== "undefined" && window.localStorage) {
+ localStorage.setItem("showReservedPlugins", showReserved.value.toString());
+ }
+ };
+
+ const toast = (message, success) => {
+ snack_message.value = message;
+ snack_show.value = true;
+ snack_success.value = success;
+ };
+
+ const resetLoadingDialog = () => {
+ loadingDialog.show = false;
+ loadingDialog.title = tm("dialogs.loading.title");
+ loadingDialog.statusCode = 0;
+ loadingDialog.result = "";
+ };
+
+ const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
+ loadingDialog.statusCode = statusCode;
+ loadingDialog.result = result;
+ if (timeToClose === -1) return;
+ setTimeout(resetLoadingDialog, timeToClose);
+ };
+
+ const failedPluginsDict = ref({});
+
+ const getExtensions = async () => {
+ loading_.value = true;
+ try {
+ const res = await axios.get("/api/plugin/get");
+ Object.assign(extension_data, res.data);
+
+ const failRes = await axios.get("/api/plugin/source/get-failed-plugins");
+ failedPluginsDict.value = failRes.data.data || {};
+
+ checkUpdate();
+ } catch (err) {
+ toast(err, "error");
+ } finally {
+ loading_.value = false;
+ }
+ };
+
+ const handleReloadAllFailed = async () => {
+ const dirNames = Object.keys(failedPluginsDict.value);
+ if (dirNames.length === 0) {
+ toast("没有需要重载的失败插件", "info");
+ return;
+ }
+
+ loading_.value = true;
+ try {
+ const promises = dirNames.map(dir =>
+ axios.post("/api/plugin/reload-failed", { dir_name: dir })
+ );
+ await Promise.all(promises);
+
+ toast("已尝试重载所有失败插件", "success");
+
+ // 清空 message 关闭对话框
+ extension_data.message = "";
+
+ // 刷新列表
+ await getExtensions();
+
+ } catch (e) {
+ console.error("重载失败:", e);
+ toast("批量重载过程中出现错误", "error");
+ } finally {
+ loading_.value = false;
+ }
+ };
+
+ const checkUpdate = () => {
+ const onlinePluginsMap = new Map();
+ const onlinePluginsNameMap = new Map();
+
+ pluginMarketData.value.forEach((plugin) => {
+ if (plugin.repo) {
+ onlinePluginsMap.set(plugin.repo.toLowerCase(), plugin);
+ }
+ onlinePluginsNameMap.set(plugin.name, plugin);
+ });
+
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ data.forEach((extension) => {
+ const repoKey = extension.repo?.toLowerCase();
+ const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
+ const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
+ const matchedPlugin = onlinePlugin || onlinePluginByName;
+
+ if (matchedPlugin) {
+ extension.online_version = matchedPlugin.version;
+ extension.has_update =
+ extension.version !== matchedPlugin.version &&
+ matchedPlugin.version !== tm("status.unknown");
+ } else {
+ extension.has_update = false;
+ }
+ });
+ };
+
+ const uninstallExtension = async (
+ extension_name,
+ optionsOrSkipConfirm = false,
+ ) => {
+ let deleteConfig = false;
+ let deleteData = false;
+ let skipConfirm = false;
+
+ // 处理参数:可能是布尔值(旧的 skipConfirm)或对象(新的选项)
+ if (typeof optionsOrSkipConfirm === "boolean") {
+ skipConfirm = optionsOrSkipConfirm;
+ } else if (
+ typeof optionsOrSkipConfirm === "object" &&
+ optionsOrSkipConfirm !== null
+ ) {
+ deleteConfig = optionsOrSkipConfirm.deleteConfig || false;
+ deleteData = optionsOrSkipConfirm.deleteData || false;
+ skipConfirm = true; // 如果传递了选项对象,说明已经确认过了
+ }
+
+ // 如果没有跳过确认且没有传递选项对象,显示自定义卸载对话框
+ if (!skipConfirm) {
+ pluginToUninstall.value = extension_name;
+ showUninstallDialog.value = true;
+ return; // 等待对话框回调
+ }
+
+ // 执行卸载
+ toast(tm("messages.uninstalling") + " " + extension_name, "primary");
+ try {
+ const res = await axios.post("/api/plugin/uninstall", {
+ name: extension_name,
+ delete_config: deleteConfig,
+ delete_data: deleteData,
+ });
+ if (res.data.status === "error") {
+ toast(res.data.message, "error");
+ return;
+ }
+ Object.assign(extension_data, res.data);
+ toast(res.data.message, "success");
+ getExtensions();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ // 处理卸载确认对话框的确认事件
+ const handleUninstallConfirm = (options) => {
+ if (pluginToUninstall.value) {
+ uninstallExtension(pluginToUninstall.value, options);
+ pluginToUninstall.value = null;
+ }
+ };
+
+ const updateExtension = async (extension_name, forceUpdate = false) => {
+ // 查找插件信息
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ const ext = data.find((e) => e.name === extension_name);
+
+ // 如果没有检测到更新且不是强制更新,则弹窗确认
+ if (!ext?.has_update && !forceUpdate) {
+ forceUpdateDialog.extensionName = extension_name;
+ forceUpdateDialog.show = true;
+ return;
+ }
+
+ loadingDialog.title = tm("status.loading");
+ loadingDialog.show = true;
+ try {
+ const res = await axios.post("/api/plugin/update", {
+ name: extension_name,
+ proxy: getSelectedGitHubProxy(),
+ });
+
+ if (res.data.status === "error") {
+ onLoadingDialogResult(2, res.data.message, -1);
+ return;
+ }
+
+ Object.assign(extension_data, res.data);
+ onLoadingDialogResult(1, res.data.message);
+ setTimeout(async () => {
+ toast(tm("messages.refreshing"), "info", 2000);
+ try {
+ await getExtensions();
+ toast(tm("messages.refreshSuccess"), "success");
+
+ // 更新完成后弹出更新日志
+ viewChangelog({
+ name: extension_name,
+ repo: ext?.repo || null,
+ });
+ } catch (error) {
+ const errorMsg =
+ error.response?.data?.message || error.message || String(error);
+ toast(`${tm("messages.refreshFailed")}: ${errorMsg}`, "error");
+ }
+ }, 1000);
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ // 确认强制更新
+ // 显示更新全部插件确认对话框
+ const showUpdateAllConfirm = () => {
+ if (updatableExtensions.value.length === 0) return;
+ updateAllConfirmDialog.show = true;
+ };
+
+ // 确认更新全部插件
+ const confirmUpdateAll = () => {
+ updateAllConfirmDialog.show = false;
+ updateAllExtensions();
+ };
+
+ // 取消更新全部插件
+ const cancelUpdateAll = () => {
+ updateAllConfirmDialog.show = false;
+ };
+
+ const confirmForceUpdate = () => {
+ const name = forceUpdateDialog.extensionName;
+ forceUpdateDialog.show = false;
+ forceUpdateDialog.extensionName = "";
+ updateExtension(name, true);
+ };
+
+ const updateAllExtensions = async () => {
+ if (updatingAll.value || updatableExtensions.value.length === 0) return;
+ updatingAll.value = true;
+ loadingDialog.title = tm("status.loading");
+ loadingDialog.statusCode = 0;
+ loadingDialog.result = "";
+ loadingDialog.show = true;
+
+ const targets = updatableExtensions.value.map((ext) => ext.name);
+ try {
+ const res = await axios.post("/api/plugin/update-all", {
+ names: targets,
+ proxy: getSelectedGitHubProxy(),
+ });
+
+ if (res.data.status === "error") {
+ onLoadingDialogResult(
+ 2,
+ res.data.message ||
+ tm("messages.updateAllFailed", {
+ failed: targets.length,
+ total: targets.length,
+ }),
+ -1,
+ );
+ return;
+ }
+
+ const results = res.data.data?.results || [];
+ const failures = results.filter((r) => r.status !== "ok");
+ try {
+ await getExtensions();
+ } catch (err) {
+ const errorMsg =
+ err.response?.data?.message || err.message || String(err);
+ failures.push({ name: "refresh", status: "error", message: errorMsg });
+ }
+
+ if (failures.length === 0) {
+ onLoadingDialogResult(1, tm("messages.updateAllSuccess"));
+ } else {
+ const failureText = tm("messages.updateAllFailed", {
+ failed: failures.length,
+ total: targets.length,
+ });
+ const detail = failures.map((f) => `${f.name}: ${f.message}`).join("\n");
+ onLoadingDialogResult(2, `${failureText}\n${detail}`, -1);
+ }
+ } catch (err) {
+ const errorMsg = err.response?.data?.message || err.message || String(err);
+ onLoadingDialogResult(2, errorMsg, -1);
+ } finally {
+ updatingAll.value = false;
+ }
+ };
+
+ const pluginOn = async (extension) => {
+ try {
+ const res = await axios.post("/api/plugin/on", { name: extension.name });
+ if (res.data.status === "error") {
+ toast(res.data.message, "error");
+ return;
+ }
+ toast(res.data.message, "success");
+ await getExtensions();
+
+ await checkAndPromptConflicts();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const pluginOff = async (extension) => {
+ try {
+ const res = await axios.post("/api/plugin/off", { name: extension.name });
+ if (res.data.status === "error") {
+ toast(res.data.message, "error");
+ return;
+ }
+ toast(res.data.message, "success");
+ getExtensions();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const openExtensionConfig = async (extension_name) => {
+ curr_namespace.value = extension_name;
+ configDialog.value = true;
+ try {
+ const res = await axios.get(
+ "/api/config/get?plugin_name=" + extension_name,
+ );
+ extension_config.metadata = res.data.data.metadata;
+ extension_config.config = res.data.data.config;
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const updateConfig = async () => {
+ try {
+ const res = await axios.post(
+ "/api/config/plugin/update?plugin_name=" + curr_namespace.value,
+ extension_config.config,
+ );
+ if (res.data.status === "ok") {
+ toast(res.data.message, "success");
+ } else {
+ toast(res.data.message, "error");
+ }
+ configDialog.value = false;
+ extension_config.metadata = {};
+ extension_config.config = {};
+ getExtensions();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const showPluginInfo = (plugin) => {
+ selectedPlugin.value = plugin;
+ showPluginInfoDialog.value = true;
+ };
+
+ const reloadPlugin = async (plugin_name) => {
+ try {
+ const res = await axios.post("/api/plugin/reload", { name: plugin_name });
+ await getExtensions();
+ if (res.data.status === "error") {
+ toast(res.data.message, "error");
+ return;
+ }
+ toast(tm("messages.reloadSuccess"), "success");
+ //getExtensions();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const viewReadme = (plugin) => {
+ readmeDialog.pluginName = plugin.name;
+ readmeDialog.repoUrl = plugin.repo;
+ readmeDialog.show = true;
+ };
+
+ // 查看更新日志
+ const viewChangelog = (plugin) => {
+ changelogDialog.pluginName = plugin.name;
+ changelogDialog.repoUrl = plugin.repo;
+ changelogDialog.show = true;
+ };
+
+ // 为表格视图创建一个处理安装插件的函数
+ const handleInstallPlugin = async (plugin) => {
+ if (plugin.tags && plugin.tags.includes("danger")) {
+ selectedDangerPlugin.value = plugin;
+ dangerConfirmDialog.value = true;
+ } else {
+ selectedMarketInstallPlugin.value = plugin;
+ extension_url.value = plugin.repo;
+ dialog.value = true;
+ uploadTab.value = "url";
+ }
+ };
+
+ // 确认安装危险插件
+ const confirmDangerInstall = () => {
+ if (selectedDangerPlugin.value) {
+ selectedMarketInstallPlugin.value = selectedDangerPlugin.value;
+ extension_url.value = selectedDangerPlugin.value.repo;
+ dialog.value = true;
+ uploadTab.value = "url";
+ }
+ dangerConfirmDialog.value = false;
+ selectedDangerPlugin.value = null;
+ };
+
+ // 取消安装危险插件
+ const cancelDangerInstall = () => {
+ dangerConfirmDialog.value = false;
+ selectedDangerPlugin.value = null;
+ };
+
+ // 自定义插件源管理方法
+ const loadCustomSources = async () => {
+ try {
+ const res = await axios.get("/api/plugin/source/get");
+ if (res.data.status === "ok") {
+ customSources.value = res.data.data;
+ } else {
+ toast(res.data.message, "error");
+ }
+ } catch (e) {
+ console.warn("Failed to load custom sources:", e);
+ customSources.value = [];
+ }
+
+ // 加载当前选中的插件源
+ const currentSource = localStorage.getItem("selectedPluginSource");
+ if (currentSource) {
+ selectedSource.value = currentSource;
+ }
+ };
+
+ const saveCustomSources = async () => {
+ try {
+ const res = await axios.post("/api/plugin/source/save", {
+ sources: customSources.value,
+ });
+ if (res.data.status !== "ok") {
+ toast(res.data.message, "error");
+ }
+ } catch (e) {
+ toast(e, "error");
+ }
+ };
+
+ const addCustomSource = () => {
+ showSourceManagerDialog.value = false;
+ editingSource.value = false;
+ originalSourceUrl.value = "";
+ sourceName.value = "";
+ sourceUrl.value = "";
+ showSourceDialog.value = true;
+ };
+
+ const openSourceManagerDialog = async () => {
+ await loadCustomSources();
+ showSourceManagerDialog.value = true;
+ };
+
+ const selectPluginSource = (sourceUrl) => {
+ selectedSource.value = sourceUrl;
+ if (sourceUrl) {
+ localStorage.setItem("selectedPluginSource", sourceUrl);
+ } else {
+ localStorage.removeItem("selectedPluginSource");
+ }
+ // 重新加载插件市场数据
+ refreshPluginMarket();
+ };
+
+ const sourceSelectItems = computed(() => [
+ { title: tm("market.defaultSource"), value: "__default__" },
+ ...customSources.value.map((source) => ({
+ title: source.name,
+ value: source.url,
+ })),
+ ]);
+
+ const editCustomSource = (source) => {
+ if (!source) return;
+ showSourceManagerDialog.value = false;
+ editingSource.value = true;
+ originalSourceUrl.value = source.url;
+ sourceName.value = source.name;
+ sourceUrl.value = source.url;
+ showSourceDialog.value = true;
+ };
+
+ const removeCustomSource = (source) => {
+ if (!source) return;
+ showSourceManagerDialog.value = false;
+ sourceToRemove.value = source;
+ showRemoveSourceDialog.value = true;
+ };
+
+ const confirmRemoveSource = () => {
+ if (sourceToRemove.value) {
+ customSources.value = customSources.value.filter(
+ (s) => s.url !== sourceToRemove.value.url,
+ );
+ saveCustomSources();
+
+ // 如果删除的是当前选中的源,切换到默认源
+ if (selectedSource.value === sourceToRemove.value.url) {
+ selectedSource.value = null;
+ localStorage.removeItem("selectedPluginSource");
+ // 重新加载插件市场数据
+ refreshPluginMarket();
+ }
+
+ toast(tm("market.sourceRemoved"), "success");
+ showRemoveSourceDialog.value = false;
+ sourceToRemove.value = null;
+ }
+ };
+
+ const saveCustomSource = () => {
+ const normalizedUrl = sourceUrl.value.trim();
+
+ if (!sourceName.value.trim() || !normalizedUrl) {
+ toast(tm("messages.fillSourceNameAndUrl"), "error");
+ return;
+ }
+
+ // 检查URL格式
+ try {
+ new URL(normalizedUrl);
+ } catch (e) {
+ toast(tm("messages.invalidUrl"), "error");
+ return;
+ }
+
+ if (editingSource.value) {
+ // 编辑模式:更新现有源
+ const index = customSources.value.findIndex(
+ (s) => s.url === originalSourceUrl.value,
+ );
+ if (index !== -1) {
+ customSources.value[index] = {
+ name: sourceName.value.trim(),
+ url: normalizedUrl,
+ };
+
+ // 如果编辑的是当前选中的源,更新选中源
+ if (selectedSource.value === originalSourceUrl.value) {
+ selectedSource.value = normalizedUrl;
+ localStorage.setItem("selectedPluginSource", selectedSource.value);
+ // 重新加载插件市场数据
+ refreshPluginMarket();
+ }
+ }
+ } else {
+ // 添加模式:检查是否已存在
+ if (customSources.value.some((source) => source.url === normalizedUrl)) {
+ toast(tm("market.sourceExists"), "error");
+ return;
+ }
+
+ customSources.value.push({
+ name: sourceName.value.trim(),
+ url: normalizedUrl,
+ });
+ }
+
+ saveCustomSources();
+ toast(
+ editingSource.value ? tm("market.sourceUpdated") : tm("market.sourceAdded"),
+ "success",
+ );
+
+ // 重置表单
+ sourceName.value = "";
+ sourceUrl.value = "";
+ editingSource.value = false;
+ originalSourceUrl.value = "";
+ showSourceDialog.value = false;
+ };
+
+ // 插件市场显示完整插件名称
+ const trimExtensionName = () => {
+ pluginMarketData.value.forEach((plugin) => {
+ if (plugin.name) {
+ let name = plugin.name.trim().toLowerCase();
+ if (name.startsWith("astrbot_plugin_")) {
+ plugin.trimmedName = name.substring(15);
+ } else if (name.startsWith("astrbot_") || name.startsWith("astrbot-")) {
+ plugin.trimmedName = name.substring(8);
+ } else plugin.trimmedName = plugin.name;
+ }
+ });
+ };
+
+ const checkAlreadyInstalled = () => {
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ const installedRepos = new Set(data.map((ext) => ext.repo?.toLowerCase()));
+ const installedNames = new Set(data.map((ext) => ext.name));
+ const installedByRepo = new Map(
+ data
+ .filter((ext) => ext.repo)
+ .map((ext) => [ext.repo.toLowerCase(), ext]),
+ );
+ const installedByName = new Map(data.map((ext) => [ext.name, ext]));
+
+ for (let i = 0; i < pluginMarketData.value.length; i++) {
+ const plugin = pluginMarketData.value[i];
+ const matchedInstalled =
+ (plugin.repo && installedByRepo.get(plugin.repo.toLowerCase())) ||
+ installedByName.get(plugin.name);
+
+ // 兜底:市场源未提供字段时,回填本地已安装插件中的元数据,便于在市场页直接展示
+ if (matchedInstalled) {
+ if (
+ (!Array.isArray(plugin.support_platforms) ||
+ plugin.support_platforms.length === 0) &&
+ Array.isArray(matchedInstalled.support_platforms)
+ ) {
+ plugin.support_platforms = matchedInstalled.support_platforms;
+ }
+ if (!plugin.astrbot_version && matchedInstalled.astrbot_version) {
+ plugin.astrbot_version = matchedInstalled.astrbot_version;
+ }
+ }
+
+ plugin.installed =
+ installedRepos.has(plugin.repo?.toLowerCase()) ||
+ installedNames.has(plugin.name);
+ }
+
+ let installed = [];
+ let notInstalled = [];
+ for (let i = 0; i < pluginMarketData.value.length; i++) {
+ if (pluginMarketData.value[i].installed) {
+ installed.push(pluginMarketData.value[i]);
+ } else {
+ notInstalled.push(pluginMarketData.value[i]);
+ }
+ }
+ pluginMarketData.value = notInstalled.concat(installed);
+ };
+
+ const showVersionCompatibilityWarning = (message) => {
+ versionCompatibilityDialog.message = message;
+ versionCompatibilityDialog.show = true;
+ };
+
+ const continueInstallIgnoringVersionWarning = async () => {
+ versionCompatibilityDialog.show = false;
+ await newExtension(true);
+ };
+
+ const cancelInstallOnVersionWarning = () => {
+ versionCompatibilityDialog.show = false;
+ };
+
+ const newExtension = async (ignoreVersionCheck = false) => {
+ if (extension_url.value === "" && upload_file.value === null) {
+ toast(tm("messages.fillUrlOrFile"), "error");
+ return;
+ }
+
+ if (extension_url.value !== "" && upload_file.value !== null) {
+ toast(tm("messages.dontFillBoth"), "error");
+ return;
+ }
+ loading_.value = true;
+ loadingDialog.title = tm("status.loading");
+ loadingDialog.show = true;
+ if (upload_file.value !== null) {
+ toast(tm("messages.installing"), "primary");
+ const formData = new FormData();
+ formData.append("file", upload_file.value);
+ formData.append("ignore_version_check", String(ignoreVersionCheck));
+ axios
+ .post("/api/plugin/install-upload", formData, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ })
+ .then(async (res) => {
+ loading_.value = false;
+ if (
+ res.data.status === "warning" &&
+ res.data.data?.warning_type === "astrbot_version_incompatible"
+ ) {
+ onLoadingDialogResult(2, res.data.message, -1);
+ showVersionCompatibilityWarning(res.data.message);
+ return;
+ }
+ if (res.data.status === "error") {
+ onLoadingDialogResult(2, res.data.message, -1);
+ return;
+ }
+ upload_file.value = null;
+ onLoadingDialogResult(1, res.data.message);
+ dialog.value = false;
+ await getExtensions();
+
+ viewReadme({
+ name: res.data.data.name,
+ repo: res.data.data.repo || null,
+ });
+
+ await checkAndPromptConflicts();
+ })
+ .catch((err) => {
+ loading_.value = false;
+ onLoadingDialogResult(2, err, -1);
+ });
+ } else {
+ toast(
+ tm("messages.installingFromUrl") + " " + extension_url.value,
+ "primary",
+ );
+ axios
+ .post("/api/plugin/install", {
+ url: extension_url.value,
+ proxy: getSelectedGitHubProxy(),
+ ignore_version_check: ignoreVersionCheck,
+ })
+ .then(async (res) => {
+ loading_.value = false;
+ if (
+ res.data.status === "warning" &&
+ res.data.data?.warning_type === "astrbot_version_incompatible"
+ ) {
+ onLoadingDialogResult(2, res.data.message, -1);
+ showVersionCompatibilityWarning(res.data.message);
+ return;
+ }
+ toast(res.data.message, res.data.status === "ok" ? "success" : "error");
+ if (res.data.status === "error") {
+ onLoadingDialogResult(2, res.data.message, -1);
+ return;
+ }
+ extension_url.value = "";
+ onLoadingDialogResult(1, res.data.message);
+ dialog.value = false;
+ await getExtensions();
+
+ viewReadme({
+ name: res.data.data.name,
+ repo: res.data.data.repo || null,
+ });
+
+ await checkAndPromptConflicts();
+ })
+ .catch((err) => {
+ loading_.value = false;
+ toast(tm("messages.installFailed") + " " + err, "error");
+ onLoadingDialogResult(2, err, -1);
+ });
+ }
+ };
+
+ const normalizePlatformList = (platforms) => {
+ if (!Array.isArray(platforms)) return [];
+ return platforms.filter((item) => typeof item === "string");
+ };
+
+ const getPlatformDisplayList = (platforms) => {
+ return normalizePlatformList(platforms).map((platformId) =>
+ getPlatformDisplayName(platformId),
+ );
+ };
+
+ const resolveSelectedInstallPlugin = () => {
+ if (
+ selectedMarketInstallPlugin.value &&
+ selectedMarketInstallPlugin.value.repo === extension_url.value
+ ) {
+ return selectedMarketInstallPlugin.value;
+ }
+ return pluginMarketData.value.find((plugin) => plugin.repo === extension_url.value) || null;
+ };
+
+ const selectedInstallPlugin = computed(() => resolveSelectedInstallPlugin());
+
+ const checkInstallCompatibility = async () => {
+ installCompat.checked = false;
+ installCompat.compatible = true;
+ installCompat.message = "";
+
+ const plugin = selectedInstallPlugin.value;
+ if (!plugin?.astrbot_version || uploadTab.value !== "url") {
+ return;
+ }
+
+ try {
+ const res = await axios.post("/api/plugin/check-compat", {
+ astrbot_version: plugin.astrbot_version,
+ });
+ if (res.data.status === "ok") {
+ installCompat.checked = true;
+ installCompat.compatible = !!res.data.data?.compatible;
+ installCompat.message = res.data.data?.message || "";
+ }
+ } catch (err) {
+ console.debug("Failed to check plugin compatibility:", err);
+ }
+ };
+
+ // 刷新插件市场数据
+ const refreshPluginMarket = async () => {
+ refreshingMarket.value = true;
+ try {
+ // 强制刷新插件市场数据
+ const data = await commonStore.getPluginCollections(
+ true,
+ selectedSource.value,
+ );
+ pluginMarketData.value = data;
+ trimExtensionName();
+ checkAlreadyInstalled();
+ checkUpdate();
+ refreshRandomPlugins();
+ currentPage.value = 1; // 重置到第一页
+
+ toast(tm("messages.refreshSuccess"), "success");
+ } catch (err) {
+ toast(tm("messages.refreshFailed") + " " + err, "error");
+ } finally {
+ refreshingMarket.value = false;
+ }
+ };
+
+ // 生命周期
+ onMounted(async () => {
+ if (!syncTabFromHash(getLocationHash())) {
+ if (typeof window !== "undefined") {
+ window.location.hash = `#${activeTab.value}`;
+ }
+ }
+ await getExtensions();
+
+ // 加载自定义插件源
+ loadCustomSources();
+
+ // 检查是否有 open_config 参数
+ let urlParams;
+ if (window.location.hash) {
+ // For hash mode (#/path?param=value)
+ const hashQuery = window.location.hash.split("?")[1] || "";
+ urlParams = new URLSearchParams(hashQuery);
+ } else {
+ // For history mode (/path?param=value)
+ urlParams = new URLSearchParams(window.location.search);
+ }
+ console.log("URL Parameters:", urlParams.toString());
+ const plugin_name = urlParams.get("open_config");
+ if (plugin_name) {
+ console.log(`Opening config for plugin: ${plugin_name}`);
+ openExtensionConfig(plugin_name);
+ }
+
+ try {
+ const data = await commonStore.getPluginCollections(
+ false,
+ selectedSource.value,
+ );
+ pluginMarketData.value = data;
+ trimExtensionName();
+ checkAlreadyInstalled();
+ checkUpdate();
+ refreshRandomPlugins();
+ } catch (err) {
+ toast(tm("messages.getMarketDataFailed") + " " + err, "error");
+ }
+ });
+
+ // 处理语言切换事件,重新加载插件配置以获取插件的 i18n 数据
+ const handleLocaleChange = () => {
+ // 如果配置对话框是打开的,重新加载当前插件的配置
+ if (configDialog.value && currentConfigPlugin.value) {
+ openExtensionConfig(currentConfigPlugin.value);
+ }
+ };
+
+ // 监听语言切换事件
+ window.addEventListener("astrbot-locale-changed", handleLocaleChange);
+
+ // 清理事件监听器
+ onUnmounted(() => {
+ window.removeEventListener("astrbot-locale-changed", handleLocaleChange);
+ });
+
+ // 搜索防抖处理
+ let searchDebounceTimer = null;
+ watch(marketSearch, (newVal) => {
+ if (searchDebounceTimer) {
+ clearTimeout(searchDebounceTimer);
+ }
+
+ searchDebounceTimer = setTimeout(() => {
+ debouncedMarketSearch.value = newVal;
+ // 搜索时重置到第一页
+ currentPage.value = 1;
+ }, 300); // 300ms 防抖延迟
+ });
+
+ // 监听显示模式变化并保存到 localStorage
+ watch(isListView, (newVal) => {
+ if (typeof window !== "undefined" && window.localStorage) {
+ localStorage.setItem("pluginListViewMode", String(newVal));
+ }
+ });
+
+ watch(
+ [() => dialog.value, () => extension_url.value, () => uploadTab.value],
+ async ([dialogOpen, _, currentUploadTab]) => {
+ if (!dialogOpen || currentUploadTab !== "url") {
+ installCompat.checked = false;
+ installCompat.compatible = true;
+ installCompat.message = "";
+ return;
+ }
+ await checkInstallCompatibility();
+ },
+ );
+
+ watch(
+ () => route.fullPath,
+ () => {
+ const tab = extractTabFromHash(getLocationHash());
+ if (isValidTab(tab) && tab !== activeTab.value) {
+ activeTab.value = tab;
+ }
+ },
+ );
+
+ watch(activeTab, (newTab) => {
+ if (!isValidTab(newTab)) return;
+ const currentTab = extractTabFromHash(getLocationHash());
+ if (currentTab === newTab) return;
+ const hash = getLocationHash();
+ const lastHashIndex = hash.lastIndexOf("#");
+ const nextHash =
+ lastHashIndex > 0 ? `${hash.slice(0, lastHashIndex)}#${newTab}` : `#${newTab}`;
+ if (typeof window !== "undefined") {
+ window.location.hash = nextHash;
+ }
+ });
+
+ return {
+ commonStore,
+ t,
+ tm,
+ router,
+ route,
+ getSelectedGitHubProxy,
+ conflictDialog,
+ checkAndPromptConflicts,
+ handleConflictConfirm,
+ fileInput,
+ activeTab,
+ validTabs,
+ isValidTab,
+ getLocationHash,
+ extractTabFromHash,
+ syncTabFromHash,
+ extension_data,
+ getInitialShowReserved,
+ showReserved,
+ snack_message,
+ snack_show,
+ snack_success,
+ configDialog,
+ extension_config,
+ pluginMarketData,
+ loadingDialog,
+ showPluginInfoDialog,
+ selectedPlugin,
+ curr_namespace,
+ updatingAll,
+ readmeDialog,
+ forceUpdateDialog,
+ updateAllConfirmDialog,
+ changelogDialog,
+ getInitialListViewMode,
+ isListView,
+ pluginSearch,
+ loading_,
+ currentPage,
+ dangerConfirmDialog,
+ selectedDangerPlugin,
+ selectedMarketInstallPlugin,
+ installCompat,
+ versionCompatibilityDialog,
+ showUninstallDialog,
+ pluginToUninstall,
+ showSourceDialog,
+ showSourceManagerDialog,
+ sourceName,
+ sourceUrl,
+ customSources,
+ selectedSource,
+ showRemoveSourceDialog,
+ sourceToRemove,
+ editingSource,
+ originalSourceUrl,
+ extension_url,
+ dialog,
+ upload_file,
+ uploadTab,
+ showPluginFullName,
+ marketSearch,
+ debouncedMarketSearch,
+ refreshingMarket,
+ sortBy,
+ sortOrder,
+ randomPluginNames,
+ normalizeStr,
+ toPinyinText,
+ toInitials,
+ marketCustomFilter,
+ plugin_handler_info_headers,
+ pluginHeaders,
+ filteredExtensions,
+ filteredPlugins,
+ filteredMarketPlugins,
+ sortedPlugins,
+ RANDOM_PLUGINS_COUNT,
+ randomPlugins,
+ shufflePlugins,
+ refreshRandomPlugins,
+ displayItemsPerPage,
+ totalPages,
+ paginatedPlugins,
+ updatableExtensions,
+ toggleShowReserved,
+ toast,
+ resetLoadingDialog,
+ onLoadingDialogResult,
+ failedPluginsDict,
+ getExtensions,
+ handleReloadAllFailed,
+ checkUpdate,
+ uninstallExtension,
+ handleUninstallConfirm,
+ updateExtension,
+ showUpdateAllConfirm,
+ confirmUpdateAll,
+ cancelUpdateAll,
+ confirmForceUpdate,
+ updateAllExtensions,
+ pluginOn,
+ pluginOff,
+ openExtensionConfig,
+ updateConfig,
+ showPluginInfo,
+ reloadPlugin,
+ viewReadme,
+ viewChangelog,
+ handleInstallPlugin,
+ confirmDangerInstall,
+ cancelDangerInstall,
+ loadCustomSources,
+ saveCustomSources,
+ addCustomSource,
+ openSourceManagerDialog,
+ selectPluginSource,
+ sourceSelectItems,
+ editCustomSource,
+ removeCustomSource,
+ confirmRemoveSource,
+ saveCustomSource,
+ trimExtensionName,
+ checkAlreadyInstalled,
+ showVersionCompatibilityWarning,
+ continueInstallIgnoringVersionWarning,
+ cancelInstallOnVersionWarning,
+ newExtension,
+ normalizePlatformList,
+ getPlatformDisplayList,
+ resolveSelectedInstallPlugin,
+ selectedInstallPlugin,
+ checkInstallCompatibility,
+ refreshPluginMarket,
+ handleLocaleChange,
+ searchDebounceTimer,
+ };
+};
diff --git a/dashboard/src/views/knowledge-base/DocumentDetail.vue b/dashboard/src/views/knowledge-base/DocumentDetail.vue
index fb64e628c..16d9ca67e 100644
--- a/dashboard/src/views/knowledge-base/DocumentDetail.vue
+++ b/dashboard/src/views/knowledge-base/DocumentDetail.vue
@@ -244,10 +244,13 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
+import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'
const { tm: t } = useModuleI18n('features/knowledge-base/document')
const route = useRoute()
+const confirmDialog = useConfirmDialog()
+
const kbId = ref(route.params.kbId as string)
const docId = ref(route.params.docId as string)
@@ -356,7 +359,7 @@ const viewChunk = (chunk: any) => {
// 删除分块
const deleteChunk = async (chunk: any) => {
- if (!confirm(t('chunks.deleteConfirm'))) return
+ if (!(await askForConfirmation(t('chunks.deleteConfirm'), confirmDialog))) return
try {
const response = await axios.post('/api/kb/chunk/delete', {
chunk_id: chunk.chunk_id,
diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue
index d8cf0dfc0..b29f57715 100644
--- a/dashboard/src/views/persona/PersonaManager.vue
+++ b/dashboard/src/views/persona/PersonaManager.vue
@@ -110,14 +110,25 @@
+ @saved="handlePersonaSaved" @deleted="handlePersonaDeleted" @error="showError" />
{{ viewingPersona.persona_id }}
-
+
+
+ {{ tm('buttons.edit') }}
+
+
+
@@ -260,6 +271,10 @@ import PersonaCard from './PersonaCard.vue';
import PersonaForm from '@/components/shared/PersonaForm.vue';
import CreateFolderDialog from './CreateFolderDialog.vue';
import MoveToFolderDialog from './MoveToFolderDialog.vue';
+import {
+ askForConfirmation as askForConfirmationDialog,
+ useConfirmDialog
+} from '@/utils/confirmDialog';
import type { Folder, FolderTreeNode } from '@/components/folder/types';
@@ -294,7 +309,8 @@ export default defineComponent({
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/persona');
- return { t, tm };
+ const confirmDialog = useConfirmDialog();
+ return { t, tm, confirmDialog };
},
data() {
return {
@@ -409,13 +425,30 @@ export default defineComponent({
this.showViewDialog = true;
},
+ openEditFromViewDialog() {
+ if (!this.viewingPersona) return;
+ this.editingPersona = this.viewingPersona;
+ this.showViewDialog = false;
+ this.showPersonaDialog = true;
+ },
+
handlePersonaSaved(message: string) {
this.showSuccess(message);
this.refreshCurrentFolder();
},
+ handlePersonaDeleted(message: string) {
+ this.showSuccess(message);
+ this.refreshCurrentFolder();
+ },
+
async confirmDeletePersona(persona: Persona) {
- if (!confirm(this.tm('messages.deleteConfirm', { id: persona.persona_id }))) {
+ if (
+ !(await askForConfirmationDialog(
+ this.tm('messages.deleteConfirm', { id: persona.persona_id }),
+ this.confirmDialog,
+ ))
+ ) {
return;
}
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
index b000b15f2..7820a40b1 100644
--- a/dashboard/tsconfig.json
+++ b/dashboard/tsconfig.json
@@ -1,31 +1,15 @@
{
+ "extends": "@vue/tsconfig/tsconfig.dom.json",
+ "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/types/.d.ts"],
"compilerOptions": {
- "target": "ESNext",
- "useDefineForClassFields": true,
- "module": "ESNext",
- "moduleResolution": "Node",
- "strict": true,
- "jsx": "preserve",
- "sourceMap": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "esModuleInterop": true,
- "lib": ["ESNext", "DOM"],
- "skipLibCheck": true,
- "noEmit": true,
+ "ignoreDeprecations": "5.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
- "allowJs": true,
- "ignoreDeprecations": "5.0"
+ "allowJs": true
},
- "include": [
- "env.d.ts",
- "src/**/*",
- "src/**/*.vue",
- "src/types/.d.ts"
- ],
+
"references": [
{
"path": "./tsconfig.vite-config.json"
diff --git a/dashboard/tsconfig.vite-config.json b/dashboard/tsconfig.vite-config.json
index 85453b690..a3d4b2151 100644
--- a/dashboard/tsconfig.vite-config.json
+++ b/dashboard/tsconfig.vite-config.json
@@ -1,11 +1,9 @@
{
+ "extends": "@vue/tsconfig/tsconfig.json",
+ "include": ["vite.config.*"],
"compilerOptions": {
"composite": true,
- "module": "ESNext",
- "moduleResolution": "Node",
- "allowSyntheticDefaultImports": true,
"allowJs": true,
"types": ["node"]
- },
- "include": ["vite.config.*"]
+ }
}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
index a5168eef7..b53e0310d 100644
--- a/dashboard/vite.config.ts
+++ b/dashboard/vite.config.ts
@@ -1,7 +1,7 @@
-import { fileURLToPath, URL } from "url";
-import { defineConfig } from "vite";
-import vue from "@vitejs/plugin-vue";
-import vuetify from "vite-plugin-vuetify";
+import { fileURLToPath, URL } from 'url';
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+import vuetify from 'vite-plugin-vuetify';
// https://vitejs.dev/config/
export default defineConfig({
@@ -9,40 +9,42 @@ export default defineConfig({
vue({
template: {
compilerOptions: {
- isCustomElement: (tag) => ["v-list-recognize-title"].includes(tag),
- },
- },
+ isCustomElement: (tag) => ['v-list-recognize-title'].includes(tag)
+ }
+ }
}),
vuetify({
- autoImport: true,
- }),
+ autoImport: true
+ })
],
resolve: {
alias: {
- mermaid: "mermaid/dist/mermaid.js",
- "@": fileURLToPath(new URL("./src", import.meta.url)),
- },
+ mermaid: 'mermaid/dist/mermaid.js',
+ '@': fileURLToPath(new URL('./src', import.meta.url))
+ }
},
css: {
preprocessorOptions: {
- scss: {},
- },
+ scss: {}
+ }
},
build: {
- chunkSizeWarningLimit: 1024 * 1024, // Set the limit to 1 MB
+ sourcemap: false,
+ chunkSizeWarningLimit: 1024 * 1024 // Set the limit to 1 MB
},
optimizeDeps: {
- exclude: ["vuetify"],
- entries: ["./src/**/*.vue"],
+ exclude: ['vuetify'],
+ entries: ['./src/**/*.vue']
},
server: {
- host: "::",
+ host: '0.0.0.0',
port: 3000,
proxy: {
- "/api": {
- target: "http://127.0.0.1:6185/",
+ '/api': {
+ target: 'http://127.0.0.1:6185/',
changeOrigin: true,
- },
- },
- },
+ ws: true
+ }
+ }
+ }
});
diff --git a/main.py b/main.py
index 60879f065..36c46fca3 100644
--- a/main.py
+++ b/main.py
@@ -5,11 +5,26 @@
import sys
from pathlib import Path
-from astrbot.core import LogBroker, LogManager, db_helper, logger
-from astrbot.core.config.default import VERSION
-from astrbot.core.initial_loader import InitialLoader
-from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-from astrbot.core.utils.io import download_dashboard, get_dashboard_version
+import runtime_bootstrap
+
+runtime_bootstrap.initialize_runtime_bootstrap()
+
+from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
+from astrbot.core.config.default import VERSION # noqa: E402
+from astrbot.core.initial_loader import InitialLoader # noqa: E402
+from astrbot.core.utils.astrbot_path import ( # noqa: E402
+ get_astrbot_config_path,
+ get_astrbot_data_path,
+ get_astrbot_knowledge_base_path,
+ get_astrbot_plugin_path,
+ get_astrbot_root,
+ get_astrbot_site_packages_path,
+ get_astrbot_temp_path,
+)
+from astrbot.core.utils.io import ( # noqa: E402
+ download_dashboard,
+ get_dashboard_version,
+)
# 将父目录添加到 sys.path
sys.path.append(Path(__file__).parent.as_posix())
@@ -25,14 +40,24 @@
"""
-def check_env():
+def check_env() -> None:
if not (sys.version_info.major == 3 and sys.version_info.minor >= 10):
logger.error("请使用 Python3.10+ 运行本项目。")
exit()
- os.makedirs("data/config", exist_ok=True)
- os.makedirs("data/plugins", exist_ok=True)
- os.makedirs("data/temp", exist_ok=True)
+ astrbot_root = get_astrbot_root()
+ if astrbot_root not in sys.path:
+ sys.path.insert(0, astrbot_root)
+
+ site_packages_path = get_astrbot_site_packages_path()
+ if site_packages_path not in sys.path:
+ sys.path.insert(0, site_packages_path)
+
+ os.makedirs(get_astrbot_config_path(), exist_ok=True)
+ os.makedirs(get_astrbot_plugin_path(), exist_ok=True)
+ os.makedirs(get_astrbot_temp_path(), exist_ok=True)
+ os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True)
+ os.makedirs(site_packages_path, exist_ok=True)
# 针对问题 #181 的临时解决方案
mimetypes.add_type("text/javascript", ".js")
diff --git a/openapi.json b/openapi.json
new file mode 100644
index 000000000..2fadecbc0
--- /dev/null
+++ b/openapi.json
@@ -0,0 +1,685 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "AstrBot Open API",
+ "version": "1.0.0",
+ "description": "Developer HTTP APIs for AstrBot. Use API Key authentication for /api/v1/* endpoints."
+ },
+ "servers": [
+ {
+ "url": "http://localhost:6185"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Open API",
+ "description": "Developer APIs authenticated by API Key"
+ }
+ ],
+ "paths": {
+ "/api/v1/im/bots": {
+ "get": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "List bot IDs",
+ "description": "Returns configured bot/platform IDs.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseBotList"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/file": {
+ "post": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "Upload attachment file",
+ "description": "Upload a file and get attachment_id for later use in chat/message APIs.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "file"
+ ],
+ "properties": {
+ "file": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseUpload"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/chat": {
+ "post": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "Send chat message (SSE)",
+ "description": "Send message to AstrBot chat pipeline and receive streaming SSE response. Reuses /api/chat/send behavior. If session_id/conversation_id is omitted, server will create a new UUID session_id.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChatSendRequest"
+ },
+ "examples": {
+ "plain": {
+ "value": {
+ "message": "Hello",
+ "username": "alice",
+ "session_id": "my_session_001",
+ "enable_streaming": true
+ }
+ },
+ "multipartMessage": {
+ "value": {
+ "message": [
+ {
+ "type": "plain",
+ "text": "Please analyze this file"
+ },
+ {
+ "type": "file",
+ "attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
+ }
+ ],
+ "username": "alice",
+ "session_id": "my_session_001",
+ "selected_provider": "openai_chat_completion",
+ "selected_model": "gpt-4.1-mini",
+ "enable_streaming": true
+ }
+ },
+ "withConfig": {
+ "value": {
+ "message": "Use a specific config for this session",
+ "username": "alice",
+ "session_id": "my_session_001",
+ "config_id": "default",
+ "enable_streaming": true
+ }
+ },
+ "autoSessionWithUsername": {
+ "value": {
+ "message": "hello",
+ "username": "alice",
+ "enable_streaming": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "SSE stream",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/chat/sessions": {
+ "get": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "List chat sessions with pagination",
+ "description": "List chat sessions for the specified username.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "default": 1,
+ "minimum": 1
+ }
+ },
+ {
+ "name": "page_size",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "default": 20,
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ {
+ "name": "platform_id",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Optional platform filter"
+ },
+ {
+ "name": "username",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Target username."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseChatSessions"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/im/message": {
+ "post": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "Send proactive message to a platform bot",
+ "description": "Send message directly to platform bot by umo + message chain payload.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SendMessageRequest"
+ },
+ "examples": {
+ "plain": {
+ "value": {
+ "umo": "webchat:FriendMessage:openapi_probe",
+ "message": "ping from api key"
+ }
+ },
+ "chain": {
+ "value": {
+ "umo": "webchat:FriendMessage:openapi_probe",
+ "message": [
+ {
+ "type": "plain",
+ "text": "hello"
+ },
+ {
+ "type": "image",
+ "attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseEmpty"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/configs": {
+ "get": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "List available chat config files",
+ "description": "Returns all available AstrBot config files that can be selected by Chat API using config_id/config_name.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseChatConfigList"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "ApiKeyHeader": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Open API key. Authorization: Bearer is also accepted."
+ }
+ },
+ "responses": {
+ "Unauthorized": {
+ "description": "Unauthorized"
+ },
+ "Forbidden": {
+ "description": "Forbidden"
+ }
+ },
+ "schemas": {
+ "ApiResponseEmpty": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "ApiResponseBotList": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "bot_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "ApiResponseUpload": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "attachment_id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "ApiResponseChatSessions": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "sessions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ChatSessionItem"
+ }
+ },
+ "page": {
+ "type": "integer"
+ },
+ "page_size": {
+ "type": "integer"
+ },
+ "total": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ },
+ "ChatSessionItem": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string"
+ },
+ "platform_id": {
+ "type": "string"
+ },
+ "creator": {
+ "type": "string"
+ },
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "is_group": {
+ "type": "integer"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "MessagePart": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "plain",
+ "reply",
+ "image",
+ "record",
+ "file",
+ "video"
+ ]
+ },
+ "text": {
+ "type": "string"
+ },
+ "message_id": {
+ "type": [
+ "string",
+ "integer"
+ ]
+ },
+ "selected_text": {
+ "type": "string"
+ },
+ "attachment_id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ]
+ },
+ "ChatSendRequest": {
+ "type": "object",
+ "required": [
+ "message",
+ "username"
+ ],
+ "properties": {
+ "message": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/MessagePart"
+ }
+ }
+ ]
+ },
+ "session_id": {
+ "type": "string",
+ "description": "Optional chat session ID. If omitted (and conversation_id is also omitted), server creates a UUID automatically."
+ },
+ "conversation_id": {
+ "type": "string",
+ "description": "Alias of session_id."
+ },
+ "username": {
+ "type": "string",
+ "description": "Target username."
+ },
+ "selected_provider": {
+ "type": "string"
+ },
+ "selected_model": {
+ "type": "string"
+ },
+ "enable_streaming": {
+ "type": "boolean",
+ "default": true
+ },
+ "config_id": {
+ "type": "string",
+ "description": "Optional AstrBot config file ID. If provided, the chat session will use this config file. Use \"default\" to reset to default config."
+ },
+ "config_name": {
+ "type": "string",
+ "description": "Optional AstrBot config file name. Used only when config_id is not provided."
+ }
+ }
+ },
+ "SendMessageRequest": {
+ "type": "object",
+ "required": [
+ "umo",
+ "message"
+ ],
+ "properties": {
+ "umo": {
+ "type": "string",
+ "description": "Unified message origin. Format: platform:message_type:session_id"
+ },
+ "message": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/MessagePart"
+ }
+ }
+ ]
+ }
+ }
+ },
+ "ChatConfigFile": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "is_default": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "path",
+ "is_default"
+ ]
+ },
+ "ApiResponseChatConfigList": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "configs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ChatConfigFile"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
index e19fbcf00..d0845cac8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,9 +1,9 @@
[project]
name = "AstrBot"
-version = "4.14.4"
+version = "4.18.3"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
-requires-python = ">=3.10"
+requires-python = ">=3.12"
keywords = ["Astrbot", "Astrbot Module", "Astrbot Plugin"]
@@ -17,14 +17,14 @@ dependencies = [
"beautifulsoup4>=4.13.4",
"certifi>=2025.4.26",
"chardet~=5.1.0",
- "colorlog>=6.9.0",
+ "loguru>=0.7.2",
"cryptography>=44.0.3",
"dashscope>=1.23.2",
"defusedxml>=0.7.1",
"deprecated>=1.2.18",
"dingtalk-stream>=0.22.1",
"docstring-parser>=0.16",
- "faiss-cpu==1.10.0",
+ "faiss-cpu>=1.12.0",
"filelock>=3.18.0",
"google-genai>=1.56.0",
"lark-oapi>=1.4.15",
@@ -36,7 +36,7 @@ dependencies = [
"pip>=25.1.1",
"psutil>=5.8.0,<7.2.0",
"py-cord>=2.6.1",
- "pydantic~=2.10.3",
+ "pydantic>=2.12.5",
"pydub>=0.25.1",
"pyjwt>=2.10.1",
"python-telegram-bot>=22.0",
@@ -61,7 +61,8 @@ dependencies = [
"xinference-client",
"tenacity>=9.1.2",
"shipyard-python-sdk>=0.2.4",
- "quart-cors>=0.8.0",
+ "python-socks>=2.8.0",
+ "packaging>=24.2",
]
[dependency-groups]
diff --git a/requirements.txt b/requirements.txt
index 1221275ec..dd19a02c3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,14 +10,14 @@ apscheduler>=3.11.0
beautifulsoup4>=4.13.4
certifi>=2025.4.26
chardet~=5.1.0
-colorlog>=6.9.0
+loguru>=0.7.2
cryptography>=44.0.3
dashscope>=1.23.2
defusedxml>=0.7.1
deprecated>=1.2.18
dingtalk-stream>=0.22.1
docstring-parser>=0.16
-faiss-cpu==1.10.0
+faiss-cpu>=1.12.0
filelock>=3.18.0
google-genai>=1.56.0
lark-oapi>=1.4.15
@@ -29,7 +29,7 @@ pillow>=11.2.1
pip>=25.1.1
psutil>=5.8.0,<7.2.0
py-cord>=2.6.1
-pydantic~=2.10.3
+pydantic>=2.12.5
pydub>=0.25.1
pyjwt>=2.10.1
python-telegram-bot>=22.0
@@ -53,4 +53,5 @@ jieba>=0.42.1
markitdown-no-magika[docx,xls,xlsx]>=0.1.2
xinference-client
tenacity>=9.1.2
-shipyard-python-sdk>=0.2.4
\ No newline at end of file
+shipyard-python-sdk>=0.2.4
+packaging>=24.2
diff --git a/runtime_bootstrap.py b/runtime_bootstrap.py
new file mode 100644
index 000000000..1e9d109d6
--- /dev/null
+++ b/runtime_bootstrap.py
@@ -0,0 +1,50 @@
+import logging
+import ssl
+from typing import Any
+
+import aiohttp.connector as aiohttp_connector
+
+from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi
+
+logger = logging.getLogger(__name__)
+
+
+def _try_patch_aiohttp_ssl_context(
+ ssl_context: ssl.SSLContext,
+ log_obj: Any | None = None,
+) -> bool:
+ log = log_obj or logger
+ attr_name = "_SSL_CONTEXT_VERIFIED"
+
+ if not hasattr(aiohttp_connector, attr_name):
+ log.warning(
+ "aiohttp connector does not expose _SSL_CONTEXT_VERIFIED; skipped patch.",
+ )
+ return False
+
+ current_value = getattr(aiohttp_connector, attr_name, None)
+ if current_value is not None and not isinstance(current_value, ssl.SSLContext):
+ log.warning(
+ "aiohttp connector exposes _SSL_CONTEXT_VERIFIED with unexpected type; skipped patch.",
+ )
+ return False
+
+ setattr(aiohttp_connector, attr_name, ssl_context)
+ log.info("Configured aiohttp verified SSL context with system+certifi trust chain.")
+ return True
+
+
+def configure_runtime_ca_bundle(log_obj: Any | None = None) -> bool:
+ log = log_obj or logger
+
+ try:
+ log.info("Bootstrapping runtime CA bundle.")
+ ssl_context = build_ssl_context_with_certifi(log_obj=log)
+ return _try_patch_aiohttp_ssl_context(ssl_context, log_obj=log)
+ except Exception as exc:
+ log.error("Failed to configure runtime CA bundle for aiohttp: %r", exc)
+ return False
+
+
+def initialize_runtime_bootstrap(log_obj: Any | None = None) -> bool:
+ return configure_runtime_ca_bundle(log_obj=log_obj)
diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py
index 446e0bc56..75b6ca88c 100755
--- a/scripts/generate_changelog.py
+++ b/scripts/generate_changelog.py
@@ -185,7 +185,7 @@ def generate_simple_changelog(commits):
return changelog_zh + changelog_en
-def main():
+def main() -> None:
parser = argparse.ArgumentParser(description="Generate changelog from git commits")
parser.add_argument(
"--version", help="Version number for the changelog (e.g., v4.13.3)"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..b9807c1de
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,381 @@
+"""
+AstrBot 测试配置
+
+提供共享的 pytest fixtures 和测试工具。
+"""
+
+import json
+import os
+import sys
+from asyncio import Queue
+from pathlib import Path
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+import pytest_asyncio
+
+# 使用 tests/fixtures/helpers.py 中的共享工具函数,避免重复定义
+from tests.fixtures.helpers import create_mock_llm_response, create_mock_message_component
+
+# 将项目根目录添加到 sys.path
+PROJECT_ROOT = Path(__file__).parent.parent
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+# 设置测试环境变量
+os.environ.setdefault("TESTING", "true")
+os.environ.setdefault("ASTRBOT_TEST_MODE", "true")
+
+
+# ============================================================
+# 测试收集和排序
+# ============================================================
+
+
+def pytest_collection_modifyitems(session, config, items): # noqa: ARG001
+ """重新排序测试:单元测试优先,集成测试在后。"""
+ unit_tests = []
+ integration_tests = []
+ deselected = []
+ profile = config.getoption("--test-profile") or os.environ.get(
+ "ASTRBOT_TEST_PROFILE", "all"
+ )
+
+ for item in items:
+ item_path = Path(str(item.path))
+ is_integration = "integration" in item_path.parts
+
+ if is_integration:
+ if item.get_closest_marker("integration") is None:
+ item.add_marker(pytest.mark.integration)
+ item.add_marker(pytest.mark.tier_d)
+ integration_tests.append(item)
+ else:
+ if item.get_closest_marker("unit") is None:
+ item.add_marker(pytest.mark.unit)
+ if any(
+ item.get_closest_marker(marker) is not None
+ for marker in ("platform", "provider", "slow")
+ ):
+ item.add_marker(pytest.mark.tier_c)
+ unit_tests.append(item)
+
+ # 单元测试 -> 集成测试
+ ordered_items = unit_tests + integration_tests
+ if profile == "blocking":
+ selected_items = []
+ for item in ordered_items:
+ if item.get_closest_marker("tier_c") or item.get_closest_marker("tier_d"):
+ deselected.append(item)
+ else:
+ selected_items.append(item)
+ if deselected:
+ config.hook.pytest_deselected(items=deselected)
+ items[:] = selected_items
+ return
+
+ items[:] = ordered_items
+
+
+def pytest_addoption(parser):
+ """增加测试执行档位选择。"""
+ parser.addoption(
+ "--test-profile",
+ action="store",
+ default=None,
+ choices=["all", "blocking"],
+ help="Select test profile. 'blocking' excludes auto-classified tier_c/tier_d tests.",
+ )
+
+
+def pytest_configure(config):
+ """注册自定义标记。"""
+ config.addinivalue_line("markers", "unit: 单元测试")
+ config.addinivalue_line("markers", "integration: 集成测试")
+ config.addinivalue_line("markers", "slow: 慢速测试")
+ config.addinivalue_line("markers", "platform: 平台适配器测试")
+ config.addinivalue_line("markers", "provider: LLM Provider 测试")
+ config.addinivalue_line("markers", "db: 数据库相关测试")
+ config.addinivalue_line("markers", "tier_c: C-tier tests (optional / non-blocking)")
+ config.addinivalue_line("markers", "tier_d: D-tier tests (extended / integration)")
+
+
+# ============================================================
+# 临时目录和文件 Fixtures
+# ============================================================
+
+
+@pytest.fixture
+def temp_dir(tmp_path: Path) -> Path:
+ """创建临时目录用于测试。"""
+ return tmp_path
+
+
+@pytest.fixture
+def event_queue() -> Queue:
+ """Create a shared asyncio queue fixture for tests."""
+ return Queue()
+
+
+@pytest.fixture
+def platform_settings() -> dict:
+ """Create a shared empty platform settings fixture for adapter tests."""
+ return {}
+
+
+@pytest.fixture
+def temp_data_dir(temp_dir: Path) -> Path:
+ """创建模拟的 data 目录结构。"""
+ data_dir = temp_dir / "data"
+ data_dir.mkdir()
+
+ # 创建必要的子目录
+ (data_dir / "config").mkdir()
+ (data_dir / "plugins").mkdir()
+ (data_dir / "temp").mkdir()
+ (data_dir / "attachments").mkdir()
+
+ return data_dir
+
+
+@pytest.fixture
+def temp_config_file(temp_data_dir: Path) -> Path:
+ """创建临时配置文件。"""
+ config_path = temp_data_dir / "config" / "cmd_config.json"
+ default_config = {
+ "provider": [],
+ "platform": [],
+ "provider_settings": {},
+ "default_personality": None,
+ "timezone": "Asia/Shanghai",
+ }
+ config_path.write_text(json.dumps(default_config, indent=2), encoding="utf-8")
+ return config_path
+
+
+@pytest.fixture
+def temp_db_file(temp_data_dir: Path) -> Path:
+ """创建临时数据库文件路径。"""
+ return temp_data_dir / "test.db"
+
+
+# ============================================================
+# Mock Fixtures
+# ============================================================
+
+
+@pytest.fixture
+def mock_provider():
+ """创建模拟的 Provider。"""
+ provider = MagicMock()
+ provider.provider_config = {
+ "id": "test-provider",
+ "type": "openai_chat_completion",
+ "model": "gpt-4o-mini",
+ }
+ provider.get_model = MagicMock(return_value="gpt-4o-mini")
+ provider.text_chat = AsyncMock()
+ provider.text_chat_stream = AsyncMock()
+ provider.terminate = AsyncMock()
+ return provider
+
+
+@pytest.fixture
+def mock_platform():
+ """创建模拟的 Platform。"""
+ platform = MagicMock()
+ platform.platform_name = "test_platform"
+ platform.platform_meta = MagicMock()
+ platform.platform_meta.support_proactive_message = False
+ platform.send_message = AsyncMock()
+ platform.terminate = AsyncMock()
+ return platform
+
+
+@pytest.fixture
+def mock_conversation():
+ """创建模拟的 Conversation。"""
+ from astrbot.core.db.po import ConversationV2
+
+ return ConversationV2(
+ conversation_id="test-conv-id",
+ platform_id="test_platform",
+ user_id="test_user",
+ content=[],
+ persona_id=None,
+ )
+
+
+@pytest.fixture
+def mock_event():
+ """创建模拟的 AstrMessageEvent。"""
+ event = MagicMock()
+ event.unified_msg_origin = "test_umo"
+ event.session_id = "test_session"
+ event.message_str = "Hello, world!"
+ event.message_obj = MagicMock()
+ event.message_obj.message = []
+ event.message_obj.sender = MagicMock()
+ event.message_obj.sender.user_id = "test_user"
+ event.message_obj.sender.nickname = "Test User"
+ event.message_obj.group_id = None
+ event.message_obj.group = None
+ event.get_platform_name = MagicMock(return_value="test_platform")
+ event.get_platform_id = MagicMock(return_value="test_platform")
+ event.get_group_id = MagicMock(return_value=None)
+ event.get_extra = MagicMock(return_value=None)
+ event.set_extra = MagicMock()
+ event.trace = MagicMock()
+ event.platform_meta = MagicMock()
+ event.platform_meta.support_proactive_message = False
+ return event
+
+
+# ============================================================
+# 配置 Fixtures
+# ============================================================
+
+
+@pytest.fixture
+def astrbot_config(temp_config_file: Path):
+ """创建 AstrBotConfig 实例。"""
+ from astrbot.core.config.astrbot_config import AstrBotConfig
+
+ config = AstrBotConfig()
+ config._config_path = str(temp_config_file) # noqa: SLF001
+ return config
+
+
+@pytest.fixture
+def main_agent_build_config():
+ """创建 MainAgentBuildConfig 实例。"""
+ from astrbot.core.astr_main_agent import MainAgentBuildConfig
+
+ return MainAgentBuildConfig(
+ tool_call_timeout=60,
+ tool_schema_mode="full",
+ provider_wake_prefix="",
+ streaming_response=True,
+ sanitize_context_by_modalities=False,
+ kb_agentic_mode=False,
+ file_extract_enabled=False,
+ context_limit_reached_strategy="truncate_by_turns",
+ llm_safety_mode=True,
+ computer_use_runtime="local",
+ add_cron_tools=True,
+ )
+
+
+# ============================================================
+# 数据库 Fixtures
+# ============================================================
+
+
+@pytest_asyncio.fixture
+async def temp_db(temp_db_file: Path):
+ """创建临时数据库实例。"""
+ from astrbot.core.db.sqlite import SQLiteDatabase
+
+ db = SQLiteDatabase(str(temp_db_file))
+ try:
+ yield db
+ finally:
+ await db.engine.dispose()
+ if temp_db_file.exists():
+ temp_db_file.unlink()
+
+
+# ============================================================
+# Context Fixtures
+# ============================================================
+
+
+@pytest_asyncio.fixture
+async def mock_context(
+ astrbot_config,
+ temp_db,
+ mock_provider,
+ mock_platform,
+):
+ """创建模拟的插件上下文。"""
+ from asyncio import Queue
+
+ from astrbot.core.star.context import Context
+
+ event_queue = Queue()
+
+ provider_manager = MagicMock()
+ provider_manager.get_using_provider = MagicMock(return_value=mock_provider)
+ provider_manager.get_provider_by_id = MagicMock(return_value=mock_provider)
+
+ platform_manager = MagicMock()
+ conversation_manager = MagicMock()
+ message_history_manager = MagicMock()
+ persona_manager = MagicMock()
+ persona_manager.personas_v3 = []
+ astrbot_config_mgr = MagicMock()
+ knowledge_base_manager = MagicMock()
+ cron_manager = MagicMock()
+ subagent_orchestrator = None
+
+ context = Context(
+ event_queue,
+ astrbot_config,
+ temp_db,
+ provider_manager,
+ platform_manager,
+ conversation_manager,
+ message_history_manager,
+ persona_manager,
+ astrbot_config_mgr,
+ knowledge_base_manager,
+ cron_manager,
+ subagent_orchestrator,
+ )
+
+ return context
+
+
+# ============================================================
+# Provider Request Fixtures
+# ============================================================
+
+
+@pytest.fixture
+def provider_request():
+ """创建 ProviderRequest 实例。"""
+ from astrbot.core.provider.entities import ProviderRequest
+
+ return ProviderRequest(
+ prompt="Hello",
+ session_id="test_session",
+ image_urls=[],
+ contexts=[],
+ system_prompt="You are a helpful assistant.",
+ )
+
+
+# ============================================================
+# 跳过条件
+# ============================================================
+
+
+def pytest_runtest_setup(item):
+ """在测试运行前检查跳过条件。"""
+ # 跳过需要 API Key 但未设置的 Provider 测试
+ if item.get_closest_marker("provider"):
+ if not os.environ.get("TEST_PROVIDER_API_KEY"):
+ pytest.skip("TEST_PROVIDER_API_KEY not set")
+
+ # 跳过需要特定平台的测试
+ if item.get_closest_marker("platform"):
+ required_platform = None
+ marker = item.get_closest_marker("platform")
+ if marker and marker.args:
+ required_platform = marker.args[0]
+
+ if required_platform and not os.environ.get(
+ f"TEST_{required_platform.upper()}_ENABLED"
+ ):
+ pytest.skip(f"TEST_{required_platform.upper()}_ENABLED not set")
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 000000000..16e927d2c
--- /dev/null
+++ b/tests/fixtures/__init__.py
@@ -0,0 +1,64 @@
+"""
+AstrBot 测试数据
+
+此目录存放测试用的静态数据和配置文件。
+
+目录结构:
+- fixtures/
+ ├── configs/ # 测试配置文件
+ ├── messages/ # 测试消息数据
+ ├── plugins/ # 测试插件
+ ├── knowledge_base/ # 测试知识库数据
+ ├── mocks/ # Mock 模块
+ └── helpers.py # 辅助函数
+"""
+
+import json
+from pathlib import Path
+
+from .helpers import (
+ NoopAwaitable,
+ create_mock_discord_attachment,
+ create_mock_discord_channel,
+ create_mock_discord_user,
+ create_mock_file,
+ create_mock_llm_response,
+ create_mock_message_component,
+ create_mock_update,
+ make_platform_config,
+)
+
+FIXTURES_DIR = Path(__file__).parent
+
+
+def load_fixture(filename: str) -> dict:
+ """加载 JSON 格式的测试数据。"""
+ filepath = FIXTURES_DIR / filename
+ if not filepath.exists():
+ raise FileNotFoundError(f"Fixture not found: {filepath}")
+ return json.loads(filepath.read_text(encoding="utf-8"))
+
+
+def get_fixture_path(filename: str) -> Path:
+ """获取测试数据文件路径。"""
+ filepath = FIXTURES_DIR / filename
+ if not filepath.exists():
+ raise FileNotFoundError(f"Fixture not found: {filepath}")
+ return filepath
+
+
+__all__ = [
+ "FIXTURES_DIR",
+ "load_fixture",
+ "get_fixture_path",
+ # 辅助函数
+ "NoopAwaitable",
+ "make_platform_config",
+ "create_mock_update",
+ "create_mock_file",
+ "create_mock_discord_attachment",
+ "create_mock_discord_user",
+ "create_mock_discord_channel",
+ "create_mock_message_component",
+ "create_mock_llm_response",
+]
diff --git a/tests/fixtures/configs/test_cmd_config.json b/tests/fixtures/configs/test_cmd_config.json
new file mode 100644
index 000000000..2b92302a4
--- /dev/null
+++ b/tests/fixtures/configs/test_cmd_config.json
@@ -0,0 +1,21 @@
+{
+ "provider": [
+ {
+ "id": "test-openai",
+ "type": "openai_chat_completion",
+ "model": "gpt-4o-mini",
+ "key": ["test-key"]
+ }
+ ],
+ "platform": [],
+ "provider_settings": {
+ "default_personality": null,
+ "prompt_prefix": "",
+ "image_caption_provider_id": "",
+ "datetime_system_prompt": true,
+ "identifier": true,
+ "group_name_display": true
+ },
+ "default_personality": null,
+ "timezone": "Asia/Shanghai"
+}
diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py
new file mode 100644
index 000000000..8f64ab6c9
--- /dev/null
+++ b/tests/fixtures/helpers.py
@@ -0,0 +1,332 @@
+"""测试辅助函数和工具类。
+
+提供统一的测试辅助工具,减少测试代码重复。
+"""
+
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+from astrbot.core.message.components import BaseMessageComponent
+
+
+class NoopAwaitable:
+ """可等待的空操作对象。
+
+ 用于 mock 需要返回 awaitable 对象的方法。
+ """
+
+ def __await__(self):
+ if False:
+ yield
+ return None
+
+
+# ============================================================
+# 平台配置工厂
+# ============================================================
+
+
+def make_platform_config(platform_type: str, **kwargs) -> dict:
+ """平台配置工厂函数。
+
+ Args:
+ platform_type: 平台类型 (telegram, discord, aiocqhttp 等)
+ **kwargs: 覆盖默认配置的字段
+
+ Returns:
+ dict: 平台配置字典
+ """
+ configs = {
+ "telegram": {
+ "id": "test_telegram",
+ "telegram_token": "test_token_123",
+ "telegram_api_base_url": "https://api.telegram.org/bot",
+ "telegram_file_base_url": "https://api.telegram.org/file/bot",
+ "telegram_command_register": True,
+ "telegram_command_auto_refresh": True,
+ "telegram_command_register_interval": 300,
+ "telegram_media_group_timeout": 2.5,
+ "telegram_media_group_max_wait": 10.0,
+ "start_message": "Welcome to AstrBot!",
+ },
+ "discord": {
+ "id": "test_discord",
+ "discord_token": "test_token_123",
+ "discord_proxy": None,
+ "discord_command_register": True,
+ "discord_guild_id_for_debug": None,
+ "discord_activity_name": "Playing AstrBot",
+ },
+ "aiocqhttp": {
+ "id": "test_aiocqhttp",
+ "ws_reverse_host": "0.0.0.0",
+ "ws_reverse_port": 6199,
+ "ws_reverse_token": "test_token",
+ },
+ "webchat": {
+ "id": "test_webchat",
+ },
+ "wecom": {
+ "id": "test_wecom",
+ "wecom_corpid": "test_corpid",
+ "wecom_secret": "test_secret",
+ },
+ }
+ config = configs.get(platform_type, {"id": f"test_{platform_type}"}).copy()
+ config.update(kwargs)
+ return config
+
+
+# ============================================================
+# Telegram 辅助函数
+# ============================================================
+
+
+def create_mock_update(
+ message_text: str | None = "Hello World",
+ chat_type: str = "private",
+ chat_id: int = 123456789,
+ user_id: int = 987654321,
+ username: str = "test_user",
+ message_id: int = 1,
+ media_group_id: str | None = None,
+ photo: list | None = None,
+ video: MagicMock | None = None,
+ document: MagicMock | None = None,
+ voice: MagicMock | None = None,
+ sticker: MagicMock | None = None,
+ reply_to_message: MagicMock | None = None,
+ caption: str | None = None,
+ entities: list | None = None,
+ caption_entities: list | None = None,
+ message_thread_id: int | None = None,
+ is_topic_message: bool = False,
+):
+ """创建模拟的 Telegram Update 对象。
+
+ Args:
+ message_text: 消息文本
+ chat_type: 聊天类型
+ chat_id: 聊天 ID
+ user_id: 用户 ID
+ username: 用户名
+ message_id: 消息 ID
+ media_group_id: 媒体组 ID
+ photo: 图片列表
+ video: 视频对象
+ document: 文档对象
+ voice: 语音对象
+ sticker: 贴纸对象
+ reply_to_message: 回复的消息
+ caption: 说明文字
+ entities: 实体列表
+ caption_entities: 说明实体列表
+ message_thread_id: 消息线程 ID
+ is_topic_message: 是否为主题消息
+
+ Returns:
+ MagicMock: 模拟的 Update 对象
+ """
+ update = MagicMock()
+ update.update_id = 1
+
+ # Create message mock
+ message = MagicMock()
+ message.message_id = message_id
+ message.chat = MagicMock()
+ message.chat.id = chat_id
+ message.chat.type = chat_type
+ message.message_thread_id = message_thread_id
+ message.is_topic_message = is_topic_message
+
+ # Create user mock
+ from_user = MagicMock()
+ from_user.id = user_id
+ from_user.username = username
+ message.from_user = from_user
+
+ # Set message content
+ message.text = message_text
+ message.media_group_id = media_group_id
+ message.photo = photo
+ message.video = video
+ message.document = document
+ message.voice = voice
+ message.sticker = sticker
+ message.reply_to_message = reply_to_message
+ message.caption = caption
+ message.entities = entities
+ message.caption_entities = caption_entities
+
+ update.message = message
+ update.effective_chat = message.chat
+
+ return update
+
+
+def create_mock_file(file_path: str = "https://api.telegram.org/file/test.jpg"):
+ """创建模拟的 Telegram File 对象。
+
+ Args:
+ file_path: 文件路径
+
+ Returns:
+ MagicMock: 模拟的 File 对象
+ """
+ file = MagicMock()
+ file.file_path = file_path
+ file.get_file = AsyncMock(return_value=file)
+ return file
+
+
+# ============================================================
+# Discord 辅助函数
+# ============================================================
+
+
+def create_mock_discord_attachment(
+ filename: str = "test.txt",
+ url: str = "https://cdn.discordapp.com/test.txt",
+ content_type: str | None = None,
+ size: int = 1024,
+):
+ """创建模拟的 Discord Attachment 对象。
+
+ Args:
+ filename: 文件名
+ url: 文件 URL
+ content_type: 内容类型
+ size: 文件大小
+
+ Returns:
+ MagicMock: 模拟的 Attachment 对象
+ """
+ attachment = MagicMock()
+ attachment.filename = filename
+ attachment.url = url
+ attachment.content_type = content_type
+ attachment.size = size
+ return attachment
+
+
+def create_mock_discord_user(
+ user_id: int = 123456789,
+ name: str = "TestUser",
+ display_name: str = "Test User",
+ bot: bool = False,
+):
+ """创建模拟的 Discord User 对象。
+
+ Args:
+ user_id: 用户 ID
+ name: 用户名
+ display_name: 显示名
+ bot: 是否为机器人
+
+ Returns:
+ MagicMock: 模拟的 User 对象
+ """
+ user = MagicMock()
+ user.id = user_id
+ user.name = name
+ user.display_name = display_name
+ user.bot = bot
+ user.mention = f"<@{user_id}>"
+ return user
+
+
+def create_mock_discord_channel(
+ channel_id: int = 111222333,
+ channel_type: str = "text",
+ name: str = "general",
+ guild_id: int | None = 444555666,
+):
+ """创建模拟的 Discord Channel 对象。
+
+ Args:
+ channel_id: 频道 ID
+ channel_type: 频道类型
+ name: 频道名
+ guild_id: 服务器 ID
+
+ Returns:
+ MagicMock: 模拟的 Channel 对象
+ """
+ channel = MagicMock()
+ channel.id = channel_id
+ channel.name = name
+ channel.type = channel_type
+
+ if guild_id:
+ channel.guild = MagicMock()
+ channel.guild.id = guild_id
+ else:
+ channel.guild = None
+
+ return channel
+
+
+# ============================================================
+# 消息组件辅助函数
+# ============================================================
+
+
+def create_mock_message_component(
+ component_type: str,
+ **kwargs: Any,
+) -> BaseMessageComponent:
+ """创建模拟的消息组件。
+
+ Args:
+ component_type: 组件类型 (plain, image, at, reply, file)
+ **kwargs: 组件参数
+
+ Returns:
+ BaseMessageComponent: 消息组件实例
+ """
+ from astrbot.core.message import components as Comp
+
+ component_map = {
+ "plain": Comp.Plain,
+ "image": Comp.Image,
+ "at": Comp.At,
+ "reply": Comp.Reply,
+ "file": Comp.File,
+ }
+
+ component_class = component_map.get(component_type.lower())
+ if not component_class:
+ raise ValueError(f"Unknown component type: {component_type}")
+
+ return component_class(**kwargs)
+
+
+def create_mock_llm_response(
+ completion_text: str = "Hello! How can I help you?",
+ role: str = "assistant",
+ tools_call_name: list[str] | None = None,
+ tools_call_args: list[dict] | None = None,
+ tools_call_ids: list[str] | None = None,
+):
+ """创建模拟的 LLM 响应。
+
+ Args:
+ completion_text: 完成文本
+ role: 角色
+ tools_call_name: 工具调用名称列表
+ tools_call_args: 工具调用参数列表
+ tools_call_ids: 工具调用 ID 列表
+
+ Returns:
+ LLMResponse: 模拟的 LLM 响应
+ """
+ from astrbot.core.provider.entities import LLMResponse, TokenUsage
+
+ return LLMResponse(
+ role=role,
+ completion_text=completion_text,
+ tools_call_name=tools_call_name or [],
+ tools_call_args=tools_call_args or [],
+ tools_call_ids=tools_call_ids or [],
+ usage=TokenUsage(input_other=10, output=5),
+ )
diff --git a/tests/fixtures/messages/test_messages.json b/tests/fixtures/messages/test_messages.json
new file mode 100644
index 000000000..0a3a7073f
--- /dev/null
+++ b/tests/fixtures/messages/test_messages.json
@@ -0,0 +1,33 @@
+{
+ "plain_message": {
+ "type": "plain",
+ "text": "Hello, this is a test message."
+ },
+ "image_message": {
+ "type": "image",
+ "url": "https://example.com/test.jpg",
+ "file": null
+ },
+ "at_message": {
+ "type": "at",
+ "user_id": "12345",
+ "nickname": "TestUser"
+ },
+ "reply_message": {
+ "type": "reply",
+ "id": "msg_123",
+ "sender_nickname": "OriginalSender",
+ "message_str": "This is the original message"
+ },
+ "file_message": {
+ "type": "file",
+ "name": "test.pdf",
+ "url": "https://example.com/test.pdf"
+ },
+ "combined_message": {
+ "components": [
+ {"type": "at", "user_id": "bot_id"},
+ {"type": "plain", "text": " Hello bot!"}
+ ]
+ }
+}
diff --git a/tests/fixtures/mocks/__init__.py b/tests/fixtures/mocks/__init__.py
new file mode 100644
index 000000000..c6497f1f2
--- /dev/null
+++ b/tests/fixtures/mocks/__init__.py
@@ -0,0 +1,43 @@
+"""测试 Mock 模块。
+
+提供统一的 mock 工具和 fixture,减少测试代码重复。
+
+使用方式:
+ # 在测试文件顶部导入需要的 fixture
+ from tests.fixtures.mocks import mock_telegram_modules
+
+ # 或使用 Builder 类创建 mock 对象
+ from tests.fixtures.mocks import MockTelegramBuilder
+ bot = MockTelegramBuilder.create_bot()
+"""
+
+from .aiocqhttp import (
+ MockAiocqhttpBuilder,
+ create_mock_aiocqhttp_modules,
+ mock_aiocqhttp_modules,
+)
+from .discord import (
+ MockDiscordBuilder,
+ create_mock_discord_modules,
+ mock_discord_modules,
+)
+from .telegram import (
+ MockTelegramBuilder,
+ create_mock_telegram_modules,
+ mock_telegram_modules,
+)
+
+__all__ = [
+ # Telegram
+ "mock_telegram_modules",
+ "create_mock_telegram_modules",
+ "MockTelegramBuilder",
+ # Discord
+ "mock_discord_modules",
+ "create_mock_discord_modules",
+ "MockDiscordBuilder",
+ # Aiocqhttp
+ "mock_aiocqhttp_modules",
+ "create_mock_aiocqhttp_modules",
+ "MockAiocqhttpBuilder",
+]
diff --git a/tests/fixtures/mocks/aiocqhttp.py b/tests/fixtures/mocks/aiocqhttp.py
new file mode 100644
index 000000000..d5e3c8229
--- /dev/null
+++ b/tests/fixtures/mocks/aiocqhttp.py
@@ -0,0 +1,58 @@
+"""Aiocqhttp 模块 Mock 工具。
+
+提供统一的 aiocqhttp 相关模块 mock 设置,避免在测试文件中重复定义。
+"""
+
+import sys
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+
+def create_mock_aiocqhttp_modules():
+ """创建 aiocqhttp 相关的 mock 模块。
+
+ Returns:
+ dict: 包含 aiocqhttp 和相关模块的 mock 对象
+ """
+ mock_aiocqhttp = MagicMock()
+ mock_aiocqhttp.CQHttp = MagicMock
+ mock_aiocqhttp.Event = MagicMock
+ mock_aiocqhttp.exceptions = MagicMock()
+ mock_aiocqhttp.exceptions.ActionFailed = Exception
+
+ return mock_aiocqhttp
+
+
+@pytest.fixture(scope="module", autouse=True)
+def mock_aiocqhttp_modules():
+ """Mock aiocqhttp 相关模块的 fixture。
+
+ 自动应用于使用此 fixture 的测试模块。
+ """
+ mock_aiocqhttp = create_mock_aiocqhttp_modules()
+ monkeypatch = pytest.MonkeyPatch()
+
+ monkeypatch.setitem(sys.modules, "aiocqhttp", mock_aiocqhttp)
+ monkeypatch.setitem(sys.modules, "aiocqhttp.exceptions", mock_aiocqhttp.exceptions)
+ yield
+ monkeypatch.undo()
+
+
+class MockAiocqhttpBuilder:
+ """构建 aiocqhttp 测试 mock 对象的工具类。"""
+
+ @staticmethod
+ def create_bot():
+ """创建 mock CQHttp bot 实例。"""
+ from tests.fixtures.helpers import NoopAwaitable
+
+ bot = MagicMock()
+ bot.send = AsyncMock()
+ bot.call_action = AsyncMock()
+ bot.on_request = MagicMock()
+ bot.on_notice = MagicMock()
+ bot.on_message = MagicMock()
+ bot.on_websocket_connection = MagicMock()
+ bot.run_task = MagicMock(return_value=NoopAwaitable())
+ return bot
diff --git a/tests/fixtures/mocks/discord.py b/tests/fixtures/mocks/discord.py
new file mode 100644
index 000000000..e13786af1
--- /dev/null
+++ b/tests/fixtures/mocks/discord.py
@@ -0,0 +1,140 @@
+"""Discord 模块 Mock 工具。
+
+提供统一的 Discord 相关模块 mock 设置,避免在测试文件中重复定义。
+"""
+
+import sys
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+
+def create_mock_discord_modules():
+ """创建 Discord 相关的 mock 模块。
+
+ Returns:
+ dict: 包含 discord 和相关模块的 mock 对象
+ """
+ mock_discord = MagicMock()
+
+ # Mock discord.Intents
+ mock_intents = MagicMock()
+ mock_intents.default = MagicMock(return_value=mock_intents)
+ mock_discord.Intents = mock_intents
+
+ # Mock discord.Status
+ mock_discord.Status = MagicMock()
+ mock_discord.Status.online = "online"
+
+ # Mock discord.Bot
+ mock_bot = MagicMock()
+ mock_discord.Bot = MagicMock(return_value=mock_bot)
+
+ # Mock discord.Embed
+ mock_embed = MagicMock()
+ mock_discord.Embed = MagicMock(return_value=mock_embed)
+
+ # Mock discord.ui
+ mock_ui = MagicMock()
+ mock_ui.View = MagicMock
+ mock_ui.Button = MagicMock
+ mock_discord.ui = mock_ui
+
+ # Mock discord.Message
+ mock_discord.Message = MagicMock
+
+ # Mock discord.Interaction
+ mock_discord.Interaction = MagicMock
+ mock_discord.InteractionType = MagicMock()
+ mock_discord.InteractionType.application_command = 2
+ mock_discord.InteractionType.component = 3
+
+ # Mock discord.File
+ mock_discord.File = MagicMock
+
+ # Mock discord.SlashCommand
+ mock_discord.SlashCommand = MagicMock
+
+ # Mock discord.Option
+ mock_discord.Option = MagicMock
+
+ # Mock discord.SlashCommandOptionType
+ mock_discord.SlashCommandOptionType = MagicMock()
+ mock_discord.SlashCommandOptionType.string = 3
+
+ # Mock discord.errors
+ mock_discord.errors = MagicMock()
+ mock_discord.errors.LoginFailure = Exception
+ mock_discord.errors.ConnectionClosed = Exception
+ mock_discord.errors.NotFound = Exception
+ mock_discord.errors.Forbidden = Exception
+
+ # Mock discord.abc
+ mock_discord.abc = MagicMock()
+ mock_discord.abc.GuildChannel = MagicMock
+ mock_discord.abc.Messageable = MagicMock
+ mock_discord.abc.PrivateChannel = MagicMock
+
+ # Mock discord.channel
+ mock_channel = MagicMock()
+ mock_channel.DMChannel = MagicMock
+ mock_discord.channel = mock_channel
+
+ # Mock discord.types
+ mock_discord.types = MagicMock()
+ mock_discord.types.interactions = MagicMock()
+
+ # Mock discord.ApplicationContext
+ mock_discord.ApplicationContext = MagicMock
+
+ # Mock discord.CustomActivity
+ mock_discord.CustomActivity = MagicMock
+
+ return mock_discord
+
+
+@pytest.fixture(scope="module", autouse=True)
+def mock_discord_modules():
+ """Mock Discord 相关模块的 fixture。
+
+ 自动应用于使用此 fixture 的测试模块。
+ """
+ mock_discord = create_mock_discord_modules()
+ monkeypatch = pytest.MonkeyPatch()
+
+ monkeypatch.setitem(sys.modules, "discord", mock_discord)
+ monkeypatch.setitem(sys.modules, "discord.abc", mock_discord.abc)
+ monkeypatch.setitem(sys.modules, "discord.channel", mock_discord.channel)
+ monkeypatch.setitem(sys.modules, "discord.errors", mock_discord.errors)
+ monkeypatch.setitem(sys.modules, "discord.types", mock_discord.types)
+ monkeypatch.setitem(
+ sys.modules,
+ "discord.types.interactions",
+ mock_discord.types.interactions,
+ )
+ monkeypatch.setitem(sys.modules, "discord.ui", mock_discord.ui)
+ yield
+ monkeypatch.undo()
+
+
+class MockDiscordBuilder:
+ """构建 Discord 测试 mock 对象的工具类。"""
+
+ @staticmethod
+ def create_client():
+ """创建 mock Discord client 实例。"""
+ client = MagicMock()
+ client.user = MagicMock()
+ client.user.id = 123456789
+ client.user.display_name = "TestBot"
+ client.user.name = "TestBot"
+ client.get_channel = MagicMock()
+ client.fetch_channel = AsyncMock()
+ client.get_message = MagicMock()
+ client.start = AsyncMock()
+ client.close = AsyncMock()
+ client.is_closed = MagicMock(return_value=False)
+ client.add_application_command = MagicMock()
+ client.sync_commands = AsyncMock()
+ client.change_presence = AsyncMock()
+ return client
diff --git a/tests/fixtures/mocks/telegram.py b/tests/fixtures/mocks/telegram.py
new file mode 100644
index 000000000..fbe4d0436
--- /dev/null
+++ b/tests/fixtures/mocks/telegram.py
@@ -0,0 +1,141 @@
+"""Telegram 模块 Mock 工具。
+
+提供统一的 Telegram 相关模块 mock 设置,避免在测试文件中重复定义。
+"""
+
+import sys
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+
+def create_mock_telegram_modules():
+ """创建 Telegram 相关的 mock 模块。
+
+ Returns:
+ dict: 包含 telegram 和相关模块的 mock 对象
+ """
+ mock_telegram = MagicMock()
+ mock_telegram.BotCommand = MagicMock
+ mock_telegram.Update = MagicMock
+ mock_telegram.constants = MagicMock()
+ mock_telegram.constants.ChatType = MagicMock()
+ mock_telegram.constants.ChatType.PRIVATE = "private"
+ mock_telegram.constants.ChatAction = MagicMock()
+ mock_telegram.constants.ChatAction.TYPING = "typing"
+ mock_telegram.constants.ChatAction.UPLOAD_VOICE = "upload_voice"
+ mock_telegram.constants.ChatAction.UPLOAD_DOCUMENT = "upload_document"
+ mock_telegram.constants.ChatAction.UPLOAD_PHOTO = "upload_photo"
+ mock_telegram.error = MagicMock()
+ mock_telegram.error.BadRequest = Exception
+ mock_telegram.ReactionTypeCustomEmoji = MagicMock
+ mock_telegram.ReactionTypeEmoji = MagicMock
+
+ mock_telegram_ext = MagicMock()
+ mock_telegram_ext.ApplicationBuilder = MagicMock
+ mock_telegram_ext.ContextTypes = MagicMock
+ mock_telegram_ext.ExtBot = MagicMock
+ mock_telegram_ext.filters = MagicMock()
+ mock_telegram_ext.filters.ALL = MagicMock()
+ mock_telegram_ext.MessageHandler = MagicMock
+
+ # Mock telegramify_markdown
+ mock_telegramify = MagicMock()
+ mock_telegramify.markdownify = lambda text, **kwargs: text
+
+ # Mock apscheduler
+ mock_apscheduler = MagicMock()
+ mock_apscheduler.schedulers = MagicMock()
+ mock_apscheduler.schedulers.asyncio = MagicMock()
+ mock_apscheduler.schedulers.asyncio.AsyncIOScheduler = MagicMock
+ mock_apscheduler.schedulers.background = MagicMock()
+ mock_apscheduler.schedulers.background.BackgroundScheduler = MagicMock
+
+ return {
+ "telegram": mock_telegram,
+ "telegram.ext": mock_telegram_ext,
+ "telegramify_markdown": mock_telegramify,
+ "apscheduler": mock_apscheduler,
+ }
+
+
+@pytest.fixture(scope="module", autouse=True)
+def mock_telegram_modules():
+ """Mock Telegram 相关模块的 fixture。
+
+ 自动应用于使用此 fixture 的测试模块。
+ """
+ mocks = create_mock_telegram_modules()
+ monkeypatch = pytest.MonkeyPatch()
+
+ monkeypatch.setitem(sys.modules, "telegram", mocks["telegram"])
+ monkeypatch.setitem(sys.modules, "telegram.constants", mocks["telegram"].constants)
+ monkeypatch.setitem(sys.modules, "telegram.error", mocks["telegram"].error)
+ monkeypatch.setitem(sys.modules, "telegram.ext", mocks["telegram.ext"])
+ monkeypatch.setitem(sys.modules, "telegramify_markdown", mocks["telegramify_markdown"])
+ monkeypatch.setitem(sys.modules, "apscheduler", mocks["apscheduler"])
+ monkeypatch.setitem(
+ sys.modules, "apscheduler.schedulers", mocks["apscheduler"].schedulers
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "apscheduler.schedulers.asyncio",
+ mocks["apscheduler"].schedulers.asyncio,
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "apscheduler.schedulers.background",
+ mocks["apscheduler"].schedulers.background,
+ )
+ yield
+ monkeypatch.undo()
+
+
+class MockTelegramBuilder:
+ """构建 Telegram 测试 mock 对象的工具类。"""
+
+ @staticmethod
+ def create_bot():
+ """创建 mock Telegram bot 实例。"""
+ bot = MagicMock()
+ bot.username = "test_bot"
+ bot.id = 12345678
+ bot.base_url = "https://api.telegram.org/bottest_token_123/"
+ bot.send_message = AsyncMock()
+ bot.send_photo = AsyncMock()
+ bot.send_document = AsyncMock()
+ bot.send_voice = AsyncMock()
+ bot.send_chat_action = AsyncMock()
+ bot.delete_my_commands = AsyncMock()
+ bot.set_my_commands = AsyncMock()
+ bot.set_message_reaction = AsyncMock()
+ bot.edit_message_text = AsyncMock()
+ return bot
+
+ @staticmethod
+ def create_application():
+ """创建 mock Telegram Application 实例。"""
+ from tests.fixtures.helpers import NoopAwaitable
+
+ app = MagicMock()
+ app.bot = MagicMock()
+ app.bot.username = "test_bot"
+ app.bot.base_url = "https://api.telegram.org/bottest_token_123/"
+ app.initialize = AsyncMock()
+ app.start = AsyncMock()
+ app.stop = AsyncMock()
+ app.add_handler = MagicMock()
+ app.updater = MagicMock()
+ app.updater.start_polling = MagicMock(return_value=NoopAwaitable())
+ app.updater.stop = AsyncMock()
+ return app
+
+ @staticmethod
+ def create_scheduler():
+ """创建 mock APScheduler 实例。"""
+ scheduler = MagicMock()
+ scheduler.add_job = MagicMock()
+ scheduler.start = MagicMock()
+ scheduler.running = True
+ scheduler.shutdown = MagicMock()
+ return scheduler
diff --git a/tests/fixtures/plugins/fixture_plugin.py b/tests/fixtures/plugins/fixture_plugin.py
new file mode 100644
index 000000000..455b5b759
--- /dev/null
+++ b/tests/fixtures/plugins/fixture_plugin.py
@@ -0,0 +1,40 @@
+"""
+测试插件 - 用于插件系统测试
+
+这是一个最小化的测试插件,用于验证插件系统的功能。
+"""
+
+from astrbot.api import llm_tool, star
+from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
+
+
+@star.register("test_plugin", "AstrBot Team", "测试插件 - 用于插件系统测试", "1.0.0")
+class TestPlugin(star.Star):
+ """测试插件类"""
+
+ def __init__(self, context: star.Context) -> None:
+ super().__init__(context)
+ self.initialized = True
+
+ async def terminate(self) -> None:
+ """插件终止"""
+ self.initialized = False
+
+ @filter.command("test_cmd")
+ async def test_command(self, event: AstrMessageEvent) -> None:
+ """测试命令处理器。"""
+ event.set_result(MessageEventResult().message("测试命令执行成功"))
+
+ @llm_tool("test_tool")
+ async def test_llm_tool(self, query: str) -> str:
+ """测试 LLM 工具。
+
+ Args:
+ query(string): 查询内容。
+ """
+ return f"测试工具执行成功: {query}"
+
+ @filter.regex(r"^test_regex_(.+)$")
+ async def test_regex_handler(self, event: AstrMessageEvent) -> None:
+ """测试正则处理器。"""
+ event.set_result(MessageEventResult().message("正则匹配成功"))
diff --git a/tests/fixtures/plugins/metadata.yaml b/tests/fixtures/plugins/metadata.yaml
new file mode 100644
index 000000000..2554fb15d
--- /dev/null
+++ b/tests/fixtures/plugins/metadata.yaml
@@ -0,0 +1,5 @@
+name: test_plugin
+description: 测试插件 - 用于插件系统测试
+version: 1.0.0
+author: AstrBot Team
+repo: https://github.com/test/test_plugin
diff --git a/tests/test_api_key_open_api.py b/tests/test_api_key_open_api.py
new file mode 100644
index 000000000..3d1ea0a0f
--- /dev/null
+++ b/tests/test_api_key_open_api.py
@@ -0,0 +1,334 @@
+import asyncio
+import uuid
+
+import pytest
+import pytest_asyncio
+from quart import Quart, g, request
+
+from astrbot.core import LogBroker
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.db.sqlite import SQLiteDatabase
+from astrbot.dashboard.routes.route import Response
+from astrbot.dashboard.server import AstrBotDashboard
+
+
+@pytest_asyncio.fixture(scope="module")
+async def core_lifecycle_td(tmp_path_factory):
+ tmp_db_path = tmp_path_factory.mktemp("data") / "test_data_api_key.db"
+ db = SQLiteDatabase(str(tmp_db_path))
+ log_broker = LogBroker()
+ core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
+ await core_lifecycle.initialize()
+ try:
+ yield core_lifecycle
+ finally:
+ try:
+ stop_result = core_lifecycle.stop()
+ if asyncio.iscoroutine(stop_result):
+ await stop_result
+ except Exception:
+ pass
+
+
+@pytest.fixture(scope="module")
+def app(core_lifecycle_td: AstrBotCoreLifecycle):
+ shutdown_event = asyncio.Event()
+ server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
+ return server.app
+
+
+@pytest_asyncio.fixture(scope="module")
+async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle):
+ test_client = app.test_client()
+ response = await test_client.post(
+ "/api/auth/login",
+ json={
+ "username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
+ "password": core_lifecycle_td.astrbot_config["dashboard"]["password"],
+ },
+ )
+ data = await response.get_json()
+ token = data["data"]["token"]
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.mark.asyncio
+async def test_api_key_scope_and_revoke(app: Quart, authenticated_header: dict):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "im-scope-key", "scopes": ["im"]},
+ headers=authenticated_header,
+ )
+ assert create_res.status_code == 200
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+ key_id = create_data["data"]["key_id"]
+
+ open_bot_res = await test_client.get(
+ "/api/v1/im/bots",
+ headers={"X-API-Key": raw_key},
+ )
+ assert open_bot_res.status_code == 200
+ open_bot_data = await open_bot_res.get_json()
+ assert open_bot_data["status"] == "ok"
+ assert isinstance(open_bot_data["data"]["bot_ids"], list)
+
+ denied_chat_sessions_res = await test_client.get(
+ "/api/v1/chat/sessions?page=1&page_size=10",
+ headers={"X-API-Key": raw_key},
+ )
+ assert denied_chat_sessions_res.status_code == 403
+
+ denied_chat_configs_res = await test_client.get(
+ "/api/v1/configs",
+ headers={"X-API-Key": raw_key},
+ )
+ assert denied_chat_configs_res.status_code == 403
+
+ denied_res = await test_client.post(
+ "/api/v1/file",
+ data={},
+ headers={"X-API-Key": raw_key},
+ )
+ assert denied_res.status_code == 403
+
+ revoke_res = await test_client.post(
+ "/api/apikey/revoke",
+ json={"key_id": key_id},
+ headers=authenticated_header,
+ )
+ assert revoke_res.status_code == 200
+ revoke_data = await revoke_res.get_json()
+ assert revoke_data["status"] == "ok"
+
+ revoked_access_res = await test_client.get(
+ "/api/v1/im/bots",
+ headers={"X-API-Key": raw_key},
+ )
+ assert revoked_access_res.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_open_send_message_with_api_key(app: Quart, authenticated_header: dict):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "send-message-key", "scopes": ["im"]},
+ headers=authenticated_header,
+ )
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+
+ send_res = await test_client.post(
+ "/api/v1/im/message",
+ json={
+ "umo": "webchat:FriendMessage:open_api_test_session",
+ "message": "hello",
+ },
+ headers={"X-API-Key": raw_key},
+ )
+ assert send_res.status_code == 200
+ send_data = await send_res.get_json()
+ assert send_data["status"] == "ok"
+
+
+@pytest.mark.asyncio
+async def test_open_chat_send_auto_session_id_and_username(
+ app: Quart,
+ authenticated_header: dict,
+ core_lifecycle_td: AstrBotCoreLifecycle,
+):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "chat-send-key", "scopes": ["chat"]},
+ headers=authenticated_header,
+ )
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+
+ rule = next(
+ (
+ item
+ for item in app.url_map.iter_rules()
+ if item.rule == "/api/v1/chat" and "POST" in item.methods
+ ),
+ None,
+ )
+ assert rule is not None
+ open_api_route = app.view_functions[rule.endpoint].__self__
+
+ original_chat = open_api_route.chat_route.chat
+
+ async def fake_chat(post_data: dict | None = None):
+ payload = post_data or await request.get_json()
+ return (
+ Response()
+ .ok(
+ data={
+ "session_id": payload.get("session_id"),
+ "creator": g.get("username"),
+ }
+ )
+ .__dict__
+ )
+
+ open_api_route.chat_route.chat = fake_chat
+ try:
+ send_res = await test_client.post(
+ "/api/v1/chat",
+ json={
+ "message": "hello",
+ "username": "alice",
+ "enable_streaming": False,
+ },
+ headers={"X-API-Key": raw_key},
+ )
+ finally:
+ open_api_route.chat_route.chat = original_chat
+
+ assert send_res.status_code == 200
+ send_data = await send_res.get_json()
+ assert send_data["status"] == "ok"
+ created_session_id = send_data["data"]["session_id"]
+ assert isinstance(created_session_id, str)
+ uuid.UUID(created_session_id)
+ assert send_data["data"]["creator"] == "alice"
+ created_session = await core_lifecycle_td.db.get_platform_session_by_id(
+ created_session_id
+ )
+ assert created_session is not None
+ assert created_session.creator == "alice"
+ assert created_session.platform_id == "webchat"
+
+ await core_lifecycle_td.db.create_platform_session(
+ creator="bob",
+ platform_id="webchat",
+ session_id="open_api_existing_bob_session",
+ is_group=0,
+ )
+ another_user_session_res = await test_client.post(
+ "/api/v1/chat",
+ json={
+ "message": "hello",
+ "username": "alice",
+ "session_id": "open_api_existing_bob_session",
+ "enable_streaming": False,
+ },
+ headers={"X-API-Key": raw_key},
+ )
+ another_user_session_data = await another_user_session_res.get_json()
+ assert another_user_session_data["status"] == "error"
+ assert (
+ another_user_session_data["message"]
+ == "session_id belongs to another username"
+ )
+
+ missing_username_res = await test_client.post(
+ "/api/v1/chat",
+ json={"message": "hello"},
+ headers={"X-API-Key": raw_key},
+ )
+ missing_username_data = await missing_username_res.get_json()
+ assert missing_username_data["status"] == "error"
+ assert missing_username_data["message"] == "Missing key: username"
+
+
+@pytest.mark.asyncio
+async def test_open_chat_sessions_pagination(
+ app: Quart,
+ authenticated_header: dict,
+ core_lifecycle_td: AstrBotCoreLifecycle,
+):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "chat-scope-key", "scopes": ["chat"]},
+ headers=authenticated_header,
+ )
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+
+ creator = "alice"
+ for idx in range(3):
+ await core_lifecycle_td.db.create_platform_session(
+ creator=creator,
+ platform_id="webchat",
+ session_id=f"open_api_paginated_{idx}",
+ display_name=f"Open API Session {idx}",
+ is_group=0,
+ )
+ await core_lifecycle_td.db.create_platform_session(
+ creator="bob",
+ platform_id="webchat",
+ session_id="open_api_paginated_bob",
+ display_name="Open API Session Bob",
+ is_group=0,
+ )
+
+ page_1_res = await test_client.get(
+ "/api/v1/chat/sessions?page=1&page_size=2&username=alice",
+ headers={"X-API-Key": raw_key},
+ )
+ assert page_1_res.status_code == 200
+ page_1_data = await page_1_res.get_json()
+ assert page_1_data["status"] == "ok"
+ assert page_1_data["data"]["page"] == 1
+ assert page_1_data["data"]["page_size"] == 2
+ assert page_1_data["data"]["total"] == 3
+ assert len(page_1_data["data"]["sessions"]) == 2
+ assert all(item["creator"] == "alice" for item in page_1_data["data"]["sessions"])
+
+ page_2_res = await test_client.get(
+ "/api/v1/chat/sessions?page=2&page_size=2&username=alice",
+ headers={"X-API-Key": raw_key},
+ )
+ assert page_2_res.status_code == 200
+ page_2_data = await page_2_res.get_json()
+ assert page_2_data["status"] == "ok"
+ assert page_2_data["data"]["page"] == 2
+ assert len(page_2_data["data"]["sessions"]) == 1
+
+ missing_username_res = await test_client.get(
+ "/api/v1/chat/sessions?page=1&page_size=2",
+ headers={"X-API-Key": raw_key},
+ )
+ missing_username_data = await missing_username_res.get_json()
+ assert missing_username_data["status"] == "error"
+ assert missing_username_data["message"] == "Missing key: username"
+
+
+@pytest.mark.asyncio
+async def test_open_chat_configs_list(
+ app: Quart,
+ authenticated_header: dict,
+):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "chat-config-key", "scopes": ["config"]},
+ headers=authenticated_header,
+ )
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+
+ configs_res = await test_client.get(
+ "/api/v1/configs",
+ headers={"X-API-Key": raw_key},
+ )
+ assert configs_res.status_code == 200
+ configs_data = await configs_res.get_json()
+ assert configs_data["status"] == "ok"
+ assert isinstance(configs_data["data"]["configs"], list)
+ assert any(item["id"] == "default" for item in configs_data["data"]["configs"])
diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py
new file mode 100644
index 000000000..3172097c7
--- /dev/null
+++ b/tests/test_openai_source.py
@@ -0,0 +1,382 @@
+from types import SimpleNamespace
+
+import pytest
+
+from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
+
+
+class _ErrorWithBody(Exception):
+ def __init__(self, message: str, body: dict):
+ super().__init__(message)
+ self.body = body
+
+
+class _ErrorWithResponse(Exception):
+ def __init__(self, message: str, response_text: str):
+ super().__init__(message)
+ self.response = SimpleNamespace(text=response_text)
+
+
+def _make_provider(overrides: dict | None = None) -> ProviderOpenAIOfficial:
+ provider_config = {
+ "id": "test-openai",
+ "type": "openai_chat_completion",
+ "model": "gpt-4o-mini",
+ "key": ["test-key"],
+ }
+ if overrides:
+ provider_config.update(overrides)
+ return ProviderOpenAIOfficial(
+ provider_config=provider_config,
+ provider_settings={},
+ )
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_removes_images():
+ provider = _make_provider(
+ {"image_moderation_error_patterns": ["file:content-moderated"]}
+ )
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+
+ success, *_rest = await provider._handle_api_error(
+ Exception("Content is moderated [WKE=file:content-moderated]"),
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+
+ assert success is False
+ updated_context = payloads["messages"]
+ assert isinstance(updated_context, list)
+ assert updated_context[0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_model_not_vlm_removes_images_and_retries_text_only():
+ provider = _make_provider()
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+
+ success, *_rest = await provider._handle_api_error(
+ Exception("The model is not a VLM and cannot process images"),
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+
+ assert success is False
+ updated_context = payloads["messages"]
+ assert isinstance(updated_context, list)
+ assert updated_context[0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_model_not_vlm_after_fallback_raises():
+ provider = _make_provider()
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+
+ with pytest.raises(Exception, match="not a VLM"):
+ await provider._handle_api_error(
+ Exception("The model is not a VLM and cannot process images"),
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=1,
+ max_retries=10,
+ image_fallback_used=True,
+ )
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_with_unserializable_body():
+ provider = _make_provider({"image_moderation_error_patterns": ["blocked"]})
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = _ErrorWithBody(
+ "upstream error",
+ {"error": {"message": "blocked"}, "raw": object()},
+ )
+
+ success, *_rest = await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ assert success is False
+ assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+def test_extract_error_text_candidates_truncates_long_response_text():
+ long_text = "x" * 20000
+ err = _ErrorWithResponse("upstream error", long_text)
+ candidates = ProviderOpenAIOfficial._extract_error_text_candidates(err)
+ assert candidates
+ assert max(len(candidate) for candidate in candidates) <= (
+ ProviderOpenAIOfficial._ERROR_TEXT_CANDIDATE_MAX_CHARS
+ )
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_without_images_raises():
+ provider = _make_provider(
+ {"image_moderation_error_patterns": ["file:content-moderated"]}
+ )
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [{"type": "text", "text": "hello"}],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = Exception("Content is moderated [WKE=file:content-moderated]")
+
+ with pytest.raises(Exception, match="content-moderated"):
+ await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_detects_structured_body():
+ provider = _make_provider(
+ {"image_moderation_error_patterns": ["content_moderated"]}
+ )
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = _ErrorWithBody(
+ "upstream error",
+ {"error": {"code": "content_moderated", "message": "blocked"}},
+ )
+
+ success, *_rest = await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ assert success is False
+ assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_supports_custom_patterns():
+ provider = _make_provider(
+ {"image_moderation_error_patterns": ["blocked_by_policy_code_123"]}
+ )
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = Exception("upstream: blocked_by_policy_code_123")
+
+ success, *_rest = await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ assert success is False
+ assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_without_patterns_raises():
+ provider = _make_provider()
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = Exception("Content is moderated [WKE=file:content-moderated]")
+
+ with pytest.raises(Exception, match="content-moderated"):
+ await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_unknown_image_error_raises():
+ provider = _make_provider()
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+
+ with pytest.raises(Exception, match="unknown provider image upload error"):
+ await provider._handle_api_error(
+ Exception("some unknown provider image upload error"),
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ finally:
+ await provider.terminate()
diff --git a/tests/test_quoted_message_parser.py b/tests/test_quoted_message_parser.py
new file mode 100644
index 000000000..0a0e126d5
--- /dev/null
+++ b/tests/test_quoted_message_parser.py
@@ -0,0 +1,494 @@
+from types import SimpleNamespace
+
+import pytest
+
+from astrbot.core.message.components import Image, Plain, Reply
+from astrbot.core.utils.quoted_message_parser import (
+ extract_quoted_message_images,
+ extract_quoted_message_text,
+)
+
+
+class _DummyAPI:
+ def __init__(
+ self,
+ responses: dict[tuple[str, str], dict],
+ param_responses: dict[tuple[str, tuple[tuple[str, str], ...]], dict]
+ | None = None,
+ ):
+ self._responses = responses
+ self._param_responses = param_responses or {}
+
+ async def call_action(self, action: str, **params):
+ param_key = (action, tuple(sorted((k, str(v)) for k, v in params.items())))
+ if param_key in self._param_responses:
+ return self._param_responses[param_key]
+
+ msg_id = params.get("message_id")
+ if msg_id is None:
+ msg_id = params.get("id")
+ key = (action, str(msg_id))
+ if key not in self._responses:
+ raise RuntimeError(f"no mock response for {key}")
+ return self._responses[key]
+
+
+class _FailIfCalledAPI:
+ async def call_action(self, action: str, **params):
+ raise AssertionError(
+ f"call_action should not be called, got action={action}, params={params}"
+ )
+
+
+def _make_event(
+ reply: Reply,
+ responses: dict[tuple[str, str], dict] | None = None,
+ param_responses: dict[tuple[str, tuple[tuple[str, str], ...]], dict] | None = None,
+):
+ if responses is None:
+ responses = {}
+ if param_responses is None:
+ param_responses = {}
+ return SimpleNamespace(
+ message_obj=SimpleNamespace(message=[reply]),
+ bot=SimpleNamespace(api=_DummyAPI(responses, param_responses)),
+ get_group_id=lambda: "",
+ )
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_from_reply_chain():
+ reply = Reply(id="1", chain=[Plain(text="quoted content")], message_str="")
+ event = _make_event(reply)
+ text = await extract_quoted_message_text(event)
+ assert text == "quoted content"
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_no_reply_component():
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[Plain(text="unquoted message")]),
+ bot=SimpleNamespace(api=_DummyAPI({}, {})),
+ get_group_id=lambda: "",
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text is None
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_no_reply_component():
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[Plain(text="unquoted message")]),
+ bot=SimpleNamespace(api=_FailIfCalledAPI()),
+ get_group_id=lambda: "",
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reply_id", [None, ""])
+async def test_extract_quoted_message_text_reply_without_id_does_not_call_get_msg(
+ reply_id: str | None,
+):
+ reply = Reply(
+ id="placeholder", chain=[Plain(text="quoted content")], message_str=""
+ )
+ object.__setattr__(reply, "id", reply_id)
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[reply]),
+ bot=SimpleNamespace(api=_FailIfCalledAPI()),
+ get_group_id=lambda: "",
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text == "quoted content"
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_fallback_get_msg_and_forward():
+ reply = Reply(id="100", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ (
+ "get_msg",
+ "100",
+ ): {
+ "data": {
+ "message": [
+ {"type": "text", "data": {"text": "parent"}},
+ {"type": "forward", "data": {"id": "fwd_1"}},
+ ]
+ }
+ },
+ (
+ "get_forward_msg",
+ "fwd_1",
+ ): {
+ "data": {
+ "messages": [
+ {
+ "sender": {"nickname": "Alice"},
+ "message": [{"type": "text", "data": {"text": "hello"}}],
+ },
+ {
+ "sender": {"nickname": "Bob"},
+ "message": [
+ {"type": "image", "data": {"url": "http://img"}},
+ {"type": "text", "data": {"text": "world"}},
+ ],
+ },
+ ]
+ }
+ },
+ },
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text is not None
+ assert "parent" in text
+ assert "Alice: hello" in text
+ assert "Bob: [Image]world" in text
+
+
+@pytest.mark.parametrize(
+ "placeholder_text",
+ [
+ "[Forward Message]",
+ "[转发消息]",
+ "[合并转发]",
+ "Alice: [Forward Message]",
+ "(Alice): [转发消息]",
+ "[Forward Message]\n[转发消息]",
+ "Alice: [Forward Message]\n(Bob): [合并转发]",
+ "[转发消息]\n\n[合并转发]",
+ ],
+)
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_forward_placeholder_variants_trigger_fallback(
+ placeholder_text: str,
+):
+ reply = Reply(id="400", chain=[Plain(text=placeholder_text)], message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "400"): {
+ "data": {
+ "message": [
+ {"type": "text", "data": {"text": "Bob: "}},
+ {"type": "image", "data": {}},
+ {"type": "text", "data": {"text": "world"}},
+ ]
+ }
+ }
+ },
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert "Bob: [Image]world" in text
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_mixed_placeholder_does_not_trigger_fallback():
+ reply = Reply(
+ id="402",
+ chain=[Plain(text="Alice: [Forward Message]\nreal text")],
+ message_str="",
+ )
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[reply]),
+ bot=SimpleNamespace(api=_FailIfCalledAPI()),
+ get_group_id=lambda: "",
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text is not None
+ assert "[Forward Message]" in text
+ assert "real text" in text
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_forward_placeholder_fallback_failure():
+ reply = Reply(id="401", chain=[Plain(text="[Forward Message]")], message_str="")
+ event = _make_event(reply, responses={})
+
+ text = await extract_quoted_message_text(event)
+ assert text == "[Forward Message]"
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_multimsg_malformed_config_does_not_raise():
+ reply = Reply(id="402", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "402"): {
+ "data": {
+ "message": [
+ {
+ "type": "json",
+ "data": {
+ "data": (
+ '{"app":"com.tencent.multimsg",'
+ '"config":"oops","meta":{}}'
+ )
+ },
+ },
+ {"type": "text", "data": {"text": "still works"}},
+ ]
+ }
+ }
+ },
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text == "still works"
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_from_reply_chain():
+ reply = Reply(
+ id="1",
+ chain=[
+ Plain(text="quoted"),
+ Image(file="https://img.example.com/a.jpg"),
+ ],
+ message_str="",
+ )
+ event = _make_event(reply)
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["https://img.example.com/a.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_fallback_get_msg_direct_url():
+ reply = Reply(id="200", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "200"): {
+ "data": {
+ "message": [
+ {
+ "type": "image",
+ "data": {"url": "https://img.example.com/direct.jpg"},
+ }
+ ]
+ }
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["https://img.example.com/direct.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_data_image_ref_normalized_to_base64():
+ data_image_ref = "data:image/png;base64,abcd1234=="
+ reply = Reply(id="201", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "201"): {
+ "data": {
+ "message": [
+ {"type": "image", "data": {"url": data_image_ref}},
+ ]
+ }
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["base64://abcd1234=="]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_file_url_with_query_string():
+ url_with_query = "https://img.example.com/direct.jpg?token=abc123#frag"
+ reply = Reply(id="205", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "205"): {
+ "data": {
+ "message": [
+ {
+ "type": "file",
+ "data": {
+ "url": url_with_query,
+ "name": "direct.jpg",
+ },
+ }
+ ]
+ }
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == [url_with_query]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_non_image_local_path_is_ignored(tmp_path):
+ non_image_file = tmp_path / "secret.txt"
+ non_image_file.write_text("not an image", encoding="utf-8")
+
+ reply = Reply(
+ id="placeholder", chain=[Image(file=str(non_image_file))], message_str=""
+ )
+ object.__setattr__(reply, "id", None)
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[reply]),
+ bot=SimpleNamespace(api=_FailIfCalledAPI()),
+ get_group_id=lambda: "",
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == []
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_chain_placeholder_triggers_fallback():
+ reply = Reply(id="210", chain=[Plain(text="[Forward Message]")], message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "210"): {
+ "data": {
+ "message": [
+ {
+ "type": "image",
+ "data": {
+ "url": "https://img.example.com/from-fallback.jpg"
+ },
+ }
+ ]
+ }
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["https://img.example.com/from-fallback.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_fallback_resolve_file_id_with_get_image():
+ reply = Reply(id="300", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "300"): {
+ "data": {"message": [{"type": "image", "data": {"file": "abc123.jpg"}}]}
+ }
+ },
+ param_responses={
+ ("get_image", (("file", "abc123.jpg"),)): {
+ "data": {"url": "https://img.example.com/resolved.jpg"}
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["https://img.example.com/resolved.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_deduplicates_across_sources():
+ dup_url = "https://img.example.com/dup.jpg"
+ chain_only_url = "https://img.example.com/only-chain.jpg"
+ get_msg_only_url = "https://img.example.com/only-get-msg.jpg"
+ forward_only_url = "https://img.example.com/only-forward.jpg"
+
+ reply = Reply(
+ id="310",
+ chain=[Image(file=dup_url), Image(file=chain_only_url)],
+ message_str="",
+ )
+
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "310"): {
+ "data": {
+ "message": [
+ {"type": "image", "data": {"url": dup_url}},
+ {"type": "image", "data": {"url": get_msg_only_url}},
+ {"type": "forward", "data": {"id": "999"}},
+ ]
+ }
+ },
+ ("get_forward_msg", "999"): {
+ "data": {
+ "messages": [
+ {
+ "sender": {"nickname": "Tester"},
+ "message": [
+ {"type": "image", "data": {"url": dup_url}},
+ {"type": "image", "data": {"url": forward_only_url}},
+ ],
+ }
+ ]
+ }
+ },
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == [
+ dup_url,
+ chain_only_url,
+ get_msg_only_url,
+ forward_only_url,
+ ]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_nested_forward_id_is_resolved():
+ nested_image = "https://img.example.com/nested.jpg"
+ reply = Reply(id="320", chain=[Plain(text="[Forward Message]")], message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "320"): {
+ "data": {"message": [{"type": "forward", "data": {"id": "fwd_1"}}]}
+ },
+ ("get_forward_msg", "fwd_1"): {
+ "data": {
+ "messages": [
+ {
+ "sender": {"nickname": "Alice"},
+ "message": [{"type": "forward", "data": {"id": "fwd_2"}}],
+ }
+ ]
+ }
+ },
+ ("get_forward_msg", "fwd_2"): {
+ "data": {
+ "messages": [
+ {
+ "sender": {"nickname": "Bob"},
+ "message": [
+ {"type": "text", "data": {"text": "deep"}},
+ {"type": "image", "data": {"url": nested_image}},
+ ],
+ }
+ ]
+ }
+ },
+ },
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text is not None
+ assert "Bob: deep" in text
+
+ images = await extract_quoted_message_images(event)
+ assert images == [nested_image]
diff --git a/tests/test_smoke.py b/tests/test_smoke.py
new file mode 100644
index 000000000..4474e1599
--- /dev/null
+++ b/tests/test_smoke.py
@@ -0,0 +1,115 @@
+"""Smoke tests for critical startup and import paths."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+from astrbot.core.pipeline.bootstrap import ensure_builtin_stages_registered
+from astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal import (
+ InternalAgentSubStage,
+)
+from astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party import (
+ ThirdPartyAgentSubStage,
+)
+from astrbot.core.pipeline.stage import Stage, registered_stages
+from astrbot.core.pipeline.stage_order import STAGES_ORDER
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+def _run_code_in_fresh_interpreter(code: str, failure_message: str) -> None:
+ proc = subprocess.run(
+ [sys.executable, "-c", code],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert proc.returncode == 0, (
+ f"{failure_message}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}\n"
+ )
+
+
+def test_smoke_critical_imports_in_fresh_interpreter() -> None:
+ code = (
+ "import importlib;"
+ "mods=["
+ "'astrbot.core.core_lifecycle',"
+ "'astrbot.core.astr_main_agent',"
+ "'astrbot.core.pipeline.scheduler',"
+ "'astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal',"
+ "'astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party'"
+ "];"
+ "[importlib.import_module(m) for m in mods]"
+ )
+ _run_code_in_fresh_interpreter(code, "Smoke import check failed.")
+
+
+def test_smoke_pipeline_stage_registration_matches_order() -> None:
+ ensure_builtin_stages_registered()
+ stage_names = {cls.__name__ for cls in registered_stages}
+
+ assert set(STAGES_ORDER).issubset(stage_names)
+ assert len(stage_names) == len(registered_stages)
+
+
+def test_smoke_agent_sub_stages_are_stage_subclasses() -> None:
+ assert issubclass(InternalAgentSubStage, Stage)
+ assert issubclass(ThirdPartyAgentSubStage, Stage)
+
+
+def test_pipeline_package_exports_remain_compatible() -> None:
+ import astrbot.core.pipeline as pipeline
+
+ assert pipeline.ProcessStage is not None
+ assert pipeline.RespondStage is not None
+ assert isinstance(pipeline.STAGES_ORDER, list)
+ assert "ProcessStage" in pipeline.STAGES_ORDER
+
+
+def test_builtin_stage_bootstrap_is_idempotent() -> None:
+ ensure_builtin_stages_registered()
+ before_count = len(registered_stages)
+ stage_names = {cls.__name__ for cls in registered_stages}
+
+ expected_stage_names = {
+ "WakingCheckStage",
+ "WhitelistCheckStage",
+ "SessionStatusCheckStage",
+ "RateLimitStage",
+ "ContentSafetyCheckStage",
+ "PreProcessStage",
+ "ProcessStage",
+ "ResultDecorateStage",
+ "RespondStage",
+ }
+
+ assert expected_stage_names.issubset(stage_names)
+
+ ensure_builtin_stages_registered()
+ assert len(registered_stages) == before_count
+
+
+def test_pipeline_import_is_stable_with_mocked_apscheduler() -> None:
+ """Regression: importing pipeline should not require cron/apscheduler modules."""
+ code = (
+ "import sys;"
+ "from unittest.mock import MagicMock;"
+ "mock_apscheduler = MagicMock();"
+ "mock_apscheduler.schedulers = MagicMock();"
+ "mock_apscheduler.schedulers.asyncio = MagicMock();"
+ "mock_apscheduler.schedulers.background = MagicMock();"
+ "sys.modules['apscheduler'] = mock_apscheduler;"
+ "sys.modules['apscheduler.schedulers'] = mock_apscheduler.schedulers;"
+ "sys.modules['apscheduler.schedulers.asyncio'] = mock_apscheduler.schedulers.asyncio;"
+ "sys.modules['apscheduler.schedulers.background'] = mock_apscheduler.schedulers.background;"
+ "import astrbot.core.pipeline as pipeline;"
+ "assert pipeline.ProcessStage is not None;"
+ "assert pipeline.RespondStage is not None"
+ )
+ _run_code_in_fresh_interpreter(
+ code,
+ "Pipeline import should not depend on real apscheduler package.",
+ )
diff --git a/tests/test_temp_dir_cleaner.py b/tests/test_temp_dir_cleaner.py
new file mode 100644
index 000000000..01f3e65d0
--- /dev/null
+++ b/tests/test_temp_dir_cleaner.py
@@ -0,0 +1,52 @@
+import os
+import time
+from pathlib import Path
+
+from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner, parse_size_to_bytes
+
+
+def test_parse_size_to_bytes():
+ assert parse_size_to_bytes("1024") == 1024 * 1024**2
+ assert parse_size_to_bytes(2048) == 2048 * 1024**2
+ assert parse_size_to_bytes("0.5") == int(0.5 * 1024**2)
+ assert parse_size_to_bytes(0) == 0
+ assert parse_size_to_bytes("invalid") == 0
+
+
+def _write_file(path: Path, size: int, mtime: float) -> None:
+ path.write_bytes(b"x" * size)
+ os.utime(path, (mtime, mtime))
+
+
+def test_cleanup_once_releases_30_percent_and_prefers_old_files(tmp_path):
+ temp_dir = tmp_path / "temp"
+ temp_dir.mkdir(parents=True, exist_ok=True)
+
+ base_time = time.time() - 1000
+ file_old = temp_dir / "old.bin"
+ file_mid = temp_dir / "mid.bin"
+ file_new = temp_dir / "new.bin"
+ _write_file(file_old, 400, base_time)
+ _write_file(file_mid, 300, base_time + 10)
+ _write_file(file_new, 300, base_time + 20)
+
+ cleaner = TempDirCleaner(max_size_getter=lambda: "0.0008", temp_dir=temp_dir)
+ cleaner.cleanup_once()
+
+ remaining_size = sum(f.stat().st_size for f in temp_dir.rglob("*") if f.is_file())
+ assert remaining_size <= 600
+ assert not file_old.exists()
+ assert file_mid.exists()
+ assert file_new.exists()
+
+
+def test_cleanup_once_noop_when_below_limit(tmp_path):
+ temp_dir = tmp_path / "temp"
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ file_path = temp_dir / "a.bin"
+ _write_file(file_path, 100, time.time())
+
+ cleaner = TempDirCleaner(max_size_getter=lambda: "1", temp_dir=temp_dir)
+ cleaner.cleanup_once()
+
+ assert file_path.exists()
diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py
index f0e90002d..c8925416b 100644
--- a/tests/test_tool_loop_agent_runner.py
+++ b/tests/test_tool_loop_agent_runner.py
@@ -90,6 +90,43 @@ async def generator():
return generator()
+class MockFailingProvider(MockProvider):
+ async def text_chat(self, **kwargs) -> LLMResponse:
+ self.call_count += 1
+ raise RuntimeError("primary provider failed")
+
+
+class MockErrProvider(MockProvider):
+ async def text_chat(self, **kwargs) -> LLMResponse:
+ self.call_count += 1
+ return LLMResponse(
+ role="err",
+ completion_text="primary provider returned error",
+ )
+
+
+class MockAbortableStreamProvider(MockProvider):
+ async def text_chat_stream(self, **kwargs):
+ abort_signal = kwargs.get("abort_signal")
+ yield LLMResponse(
+ role="assistant",
+ completion_text="partial ",
+ is_chunk=True,
+ )
+ if abort_signal and abort_signal.is_set():
+ yield LLMResponse(
+ role="assistant",
+ completion_text="partial ",
+ is_chunk=False,
+ )
+ return
+ yield LLMResponse(
+ role="assistant",
+ completion_text="partial final",
+ is_chunk=False,
+ )
+
+
class MockHooks(BaseAgentRunHooks):
"""模拟钩子函数"""
@@ -112,6 +149,20 @@ async def on_agent_done(self, run_context, llm_response):
self.agent_done_called = True
+class MockEvent:
+ def __init__(self, umo: str, sender_id: str):
+ self.unified_msg_origin = umo
+ self._sender_id = sender_id
+
+ def get_sender_id(self):
+ return self._sender_id
+
+
+class MockAgentContext:
+ def __init__(self, event):
+ self.event = event
+
+
@pytest.fixture
def mock_provider():
return MockProvider()
@@ -321,6 +372,169 @@ async def test_hooks_called_with_max_step(
assert mock_hooks.tool_end_called, "on_tool_end应该被调用"
+@pytest.mark.asyncio
+async def test_fallback_provider_used_when_primary_raises(
+ runner, provider_request, mock_tool_executor, mock_hooks
+):
+ primary_provider = MockFailingProvider()
+ fallback_provider = MockProvider()
+ fallback_provider.should_call_tools = False
+
+ await runner.reset(
+ provider=primary_provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ fallback_providers=[fallback_provider],
+ )
+
+ async for _ in runner.step_until_done(5):
+ pass
+
+ final_resp = runner.get_final_llm_resp()
+ assert final_resp is not None
+ assert final_resp.role == "assistant"
+ assert final_resp.completion_text == "这是我的最终回答"
+ assert primary_provider.call_count == 1
+ assert fallback_provider.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_fallback_provider_used_when_primary_returns_err(
+ runner, provider_request, mock_tool_executor, mock_hooks
+):
+ primary_provider = MockErrProvider()
+ fallback_provider = MockProvider()
+ fallback_provider.should_call_tools = False
+
+ await runner.reset(
+ provider=primary_provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ fallback_providers=[fallback_provider],
+ )
+
+ async for _ in runner.step_until_done(5):
+ pass
+
+ final_resp = runner.get_final_llm_resp()
+ assert final_resp is not None
+ assert final_resp.role == "assistant"
+ assert final_resp.completion_text == "这是我的最终回答"
+ assert primary_provider.call_count == 1
+ assert fallback_provider.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_stop_signal_returns_aborted_and_persists_partial_message(
+ runner, provider_request, mock_tool_executor, mock_hooks
+):
+ provider = MockAbortableStreamProvider()
+
+ await runner.reset(
+ provider=provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=True,
+ )
+
+ step_iter = runner.step()
+ first_resp = await step_iter.__anext__()
+ assert first_resp.type == "streaming_delta"
+
+ runner.request_stop()
+
+ rest_responses = []
+ async for response in step_iter:
+ rest_responses.append(response)
+
+ assert any(resp.type == "aborted" for resp in rest_responses)
+ assert runner.was_aborted() is True
+
+ final_resp = runner.get_final_llm_resp()
+ assert final_resp is not None
+ assert final_resp.role == "assistant"
+ assert final_resp.completion_text == "partial "
+ assert runner.run_context.messages[-1].role == "assistant"
+
+
+@pytest.mark.asyncio
+async def test_tool_result_injects_follow_up_notice(
+ runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
+):
+ mock_event = MockEvent("test:FriendMessage:follow_up", "u1")
+ run_context = ContextWrapper(context=MockAgentContext(mock_event))
+
+ await runner.reset(
+ provider=mock_provider,
+ request=provider_request,
+ run_context=run_context,
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ )
+
+ ticket1 = runner.follow_up(
+ message_text="follow up 1",
+ )
+ ticket2 = runner.follow_up(
+ message_text="follow up 2",
+ )
+ assert ticket1 is not None
+ assert ticket2 is not None
+
+ async for _ in runner.step():
+ pass
+
+ assert provider_request.tool_calls_result is not None
+ assert isinstance(provider_request.tool_calls_result, list)
+ assert provider_request.tool_calls_result
+ tool_result = str(
+ provider_request.tool_calls_result[0].tool_calls_result[0].content
+ )
+ assert "SYSTEM NOTICE" in tool_result
+ assert "1. follow up 1" in tool_result
+ assert "2. follow up 2" in tool_result
+ assert ticket1.resolved.is_set() is True
+ assert ticket2.resolved.is_set() is True
+ assert ticket1.consumed is True
+ assert ticket2.consumed is True
+
+
+@pytest.mark.asyncio
+async def test_follow_up_ticket_not_consumed_when_no_next_tool_call(
+ runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
+):
+ mock_provider.should_call_tools = False
+ mock_event = MockEvent("test:FriendMessage:follow_up_no_tool", "u1")
+ run_context = ContextWrapper(context=MockAgentContext(mock_event))
+
+ await runner.reset(
+ provider=mock_provider,
+ request=provider_request,
+ run_context=run_context,
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ )
+
+ ticket = runner.follow_up(message_text="follow up without tool")
+ assert ticket is not None
+
+ async for _ in runner.step():
+ pass
+
+ assert ticket.resolved.is_set() is True
+ assert ticket.consumed is False
+
+
if __name__ == "__main__":
# 运行测试
pytest.main([__file__, "-v"])
diff --git a/tests/unit/test_astr_message_event.py b/tests/unit/test_astr_message_event.py
new file mode 100644
index 000000000..ac529318f
--- /dev/null
+++ b/tests/unit/test_astr_message_event.py
@@ -0,0 +1,781 @@
+"""Tests for AstrMessageEvent class."""
+
+import re
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from astrbot.core.message.components import (
+ At,
+ AtAll,
+ Face,
+ Forward,
+ Image,
+ Plain,
+ Reply,
+)
+from astrbot.core.message.message_event_result import MessageEventResult
+from astrbot.core.platform.astr_message_event import AstrMessageEvent
+from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember
+from astrbot.core.platform.message_type import MessageType
+from astrbot.core.platform.platform_metadata import PlatformMetadata
+
+
+class ConcreteAstrMessageEvent(AstrMessageEvent):
+ """Concrete implementation of AstrMessageEvent for testing purposes."""
+
+ async def send(self, message):
+ """Send message implementation."""
+ await super().send(message)
+
+
+@pytest.fixture
+def platform_meta():
+ """Create platform metadata for testing."""
+ return PlatformMetadata(
+ name="test_platform",
+ description="Test platform",
+ id="test_platform_id",
+ )
+
+
+@pytest.fixture
+def message_member():
+ """Create a message member for testing."""
+ return MessageMember(user_id="user123", nickname="TestUser")
+
+
+@pytest.fixture
+def astrbot_message(message_member):
+ """Create an AstrBotMessage for testing."""
+ message = AstrBotMessage()
+ message.type = MessageType.FRIEND_MESSAGE
+ message.self_id = "bot123"
+ message.session_id = "session123"
+ message.message_id = "msg123"
+ message.sender = message_member
+ message.message = [Plain(text="Hello world")]
+ message.message_str = "Hello world"
+ message.raw_message = None
+ return message
+
+
+@pytest.fixture
+def astr_message_event(platform_meta, astrbot_message):
+ """Create an AstrMessageEvent instance for testing."""
+ return ConcreteAstrMessageEvent(
+ message_str="Hello world",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+
+
+class TestAstrMessageEventInit:
+ """Tests for AstrMessageEvent initialization."""
+
+ def test_init_basic(self, astr_message_event):
+ """Test basic AstrMessageEvent initialization."""
+ assert astr_message_event.message_str == "Hello world"
+ assert astr_message_event.role == "member"
+ assert astr_message_event.is_wake is False
+ assert astr_message_event.is_at_or_wake_command is False
+ assert astr_message_event._extras == {}
+ assert astr_message_event._result is None
+ assert astr_message_event.call_llm is False
+
+ def test_init_session(self, astr_message_event):
+ """Test session initialization."""
+ assert astr_message_event.session_id == "session123"
+ assert astr_message_event.session.platform_name == "test_platform_id"
+
+ def test_init_platform_reference(self, astr_message_event, platform_meta):
+ """Test platform reference initialization."""
+ assert astr_message_event.platform_meta == platform_meta
+ assert astr_message_event.platform == platform_meta # back compatibility
+
+ def test_init_created_at(self, astr_message_event):
+ """Test created_at timestamp is set."""
+ assert astr_message_event.created_at is not None
+ assert isinstance(astr_message_event.created_at, float)
+
+ def test_init_trace(self, astr_message_event):
+ """Test trace/span initialization."""
+ assert astr_message_event.trace is not None
+ assert astr_message_event.span is not None
+ assert astr_message_event.trace == astr_message_event.span
+
+
+class TestUnifiedMsgOrigin:
+ """Tests for unified_msg_origin property."""
+
+ def test_unified_msg_origin_getter(self, astr_message_event):
+ """Test unified_msg_origin getter."""
+ expected = "test_platform_id:FriendMessage:session123"
+ assert astr_message_event.unified_msg_origin == expected
+
+ def test_unified_msg_origin_setter(self, astr_message_event):
+ """Test unified_msg_origin setter."""
+ astr_message_event.unified_msg_origin = "new_platform:GroupMessage:new_session"
+
+ assert astr_message_event.session.platform_name == "new_platform"
+ assert astr_message_event.session.session_id == "new_session"
+
+
+class TestSessionId:
+ """Tests for session_id property."""
+
+ def test_session_id_getter(self, astr_message_event):
+ """Test session_id getter."""
+ assert astr_message_event.session_id == "session123"
+
+ def test_session_id_setter(self, astr_message_event):
+ """Test session_id setter."""
+ astr_message_event.session_id = "new_session_id"
+
+ assert astr_message_event.session_id == "new_session_id"
+
+
+class TestGetPlatformInfo:
+ """Tests for platform info methods."""
+
+ def test_get_platform_name(self, astr_message_event):
+ """Test get_platform_name method."""
+ assert astr_message_event.get_platform_name() == "test_platform"
+
+ def test_get_platform_id(self, astr_message_event):
+ """Test get_platform_id method."""
+ assert astr_message_event.get_platform_id() == "test_platform_id"
+
+
+class TestGetMessageInfo:
+ """Tests for message info methods."""
+
+ def test_get_message_str(self, astr_message_event):
+ """Test get_message_str method."""
+ assert astr_message_event.get_message_str() == "Hello world"
+
+ def test_get_message_str_none(self, platform_meta, astrbot_message):
+ """Test get_message_str keeps None when source message_str is None."""
+ astrbot_message.message_str = None
+ event = ConcreteAstrMessageEvent(
+ message_str=None,
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.get_message_str() is None
+
+ def test_get_messages(self, astr_message_event):
+ """Test get_messages method."""
+ messages = astr_message_event.get_messages()
+ assert len(messages) == 1
+ assert isinstance(messages[0], Plain)
+ assert messages[0].text == "Hello world"
+
+ def test_get_message_type(self, astr_message_event):
+ """Test get_message_type method."""
+ assert astr_message_event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+ def test_get_session_id(self, astr_message_event):
+ """Test get_session_id method."""
+ assert astr_message_event.get_session_id() == "session123"
+
+ def test_get_group_id_empty_for_private(self, astr_message_event):
+ """Test get_group_id returns empty for private messages."""
+ assert astr_message_event.get_group_id() == ""
+
+ def test_get_self_id(self, astr_message_event):
+ """Test get_self_id method."""
+ assert astr_message_event.get_self_id() == "bot123"
+
+ def test_get_sender_id(self, astr_message_event):
+ """Test get_sender_id method."""
+ assert astr_message_event.get_sender_id() == "user123"
+
+ def test_get_sender_name(self, astr_message_event):
+ """Test get_sender_name method."""
+ assert astr_message_event.get_sender_name() == "TestUser"
+
+ def test_get_sender_name_empty_when_none(self, platform_meta, astrbot_message):
+ """Test get_sender_name returns empty string when nickname is None."""
+ astrbot_message.sender = MessageMember(user_id="user123", nickname=None)
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.get_sender_name() == ""
+
+ def test_get_sender_name_coerces_non_string(self, platform_meta, astrbot_message):
+ """Test get_sender_name stringifies non-string nickname values."""
+ astrbot_message.sender = MessageMember(user_id="user123", nickname=None)
+ astrbot_message.sender.nickname = 12345
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.get_sender_name() == "12345"
+
+
+class TestGetMessageOutline:
+ """Tests for get_message_outline method."""
+
+ def test_outline_plain_text(self, astr_message_event):
+ """Test outline with plain text message."""
+ outline = astr_message_event.get_message_outline()
+ assert "Hello world" in outline
+
+ def test_outline_with_image(self, platform_meta, astrbot_message):
+ """Test outline with image component."""
+ astrbot_message.message = [
+ Plain(text="Look at this"),
+ Image(file="http://example.com/img.jpg"),
+ ]
+ event = ConcreteAstrMessageEvent(
+ message_str="Look at this",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "Look at this" in outline
+ assert "[图片]" in outline
+
+ def test_outline_with_at(self, platform_meta, astrbot_message):
+ """Test outline with At component."""
+ astrbot_message.message = [At(qq="12345"), Plain(text=" hello")]
+ event = ConcreteAstrMessageEvent(
+ message_str=" hello",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[At:12345]" in outline
+
+ def test_outline_with_at_all(self, platform_meta, astrbot_message):
+ """Test outline with AtAll component."""
+ astrbot_message.message = [AtAll()]
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ # AtAll format is "[At:all]" in the actual implementation
+ assert "[At:" in outline and "all" in outline.lower()
+
+ def test_outline_with_face(self, platform_meta, astrbot_message):
+ """Test outline with Face component."""
+ astrbot_message.message = [Face(id="123")]
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[表情:123]" in outline
+
+ def test_outline_with_forward(self, platform_meta, astrbot_message):
+ """Test outline with Forward component."""
+ # Forward requires an id parameter
+ astrbot_message.message = [Forward(id="test_forward_id")]
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[转发消息]" in outline
+
+ def test_outline_with_reply(self, platform_meta, astrbot_message):
+ """Test outline with Reply component."""
+ # Reply requires an id parameter
+ reply = Reply(id="test_reply_id")
+ reply.message_str = "Original message"
+ reply.sender_nickname = "Sender"
+ astrbot_message.message = [reply, Plain(text=" reply")]
+ event = ConcreteAstrMessageEvent(
+ message_str=" reply",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[引用消息(Sender: Original message)]" in outline
+
+ def test_outline_with_reply_no_message(self, platform_meta, astrbot_message):
+ """Test outline with Reply component without message_str."""
+ # Reply requires an id parameter
+ reply = Reply(id="test_reply_id")
+ reply.message_str = None
+ astrbot_message.message = [reply]
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[引用消息]" in outline
+
+ def test_outline_empty_chain(self, platform_meta, astrbot_message):
+ """Test outline with empty message chain."""
+ astrbot_message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert outline == ""
+
+ def test_outline_very_long_plain_text(self, platform_meta, astrbot_message):
+ """Test outline generation for very long plain text content."""
+ long_text = "A" * 20000
+ astrbot_message.message = [Plain(text=long_text)]
+ event = ConcreteAstrMessageEvent(
+ message_str=long_text,
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert outline.startswith("A")
+ assert len(outline) >= 20000
+
+
+class TestExtras:
+ """Tests for extra information methods."""
+
+ def test_set_extra(self, astr_message_event):
+ """Test set_extra method."""
+ astr_message_event.set_extra("key1", "value1")
+ assert astr_message_event._extras["key1"] == "value1"
+
+ def test_get_extra_with_key(self, astr_message_event):
+ """Test get_extra with specific key."""
+ astr_message_event.set_extra("key1", "value1")
+ assert astr_message_event.get_extra("key1") == "value1"
+
+ def test_get_extra_with_default(self, astr_message_event):
+ """Test get_extra with default value."""
+ result = astr_message_event.get_extra("nonexistent", "default_value")
+ assert result == "default_value"
+
+ def test_get_extra_all(self, astr_message_event):
+ """Test get_extra without key returns all extras."""
+ astr_message_event.set_extra("key1", "value1")
+ astr_message_event.set_extra("key2", "value2")
+ all_extras = astr_message_event.get_extra()
+ assert all_extras == {"key1": "value1", "key2": "value2"}
+
+ def test_clear_extra(self, astr_message_event):
+ """Test clear_extra method."""
+ astr_message_event.set_extra("key1", "value1")
+ astr_message_event.clear_extra()
+ assert astr_message_event._extras == {}
+
+
+class TestSetResult:
+ """Tests for set_result method."""
+
+ def test_set_result_with_message_event_result(self, astr_message_event):
+ """Test set_result with MessageEventResult object."""
+ result = MessageEventResult().message("Test message")
+ astr_message_event.set_result(result)
+
+ assert astr_message_event._result == result
+
+ def test_set_result_with_string(self, astr_message_event):
+ """Test set_result with string creates MessageEventResult."""
+ astr_message_event.set_result("Test message")
+
+ assert astr_message_event._result is not None
+ assert len(astr_message_event._result.chain) == 1
+ assert isinstance(astr_message_event._result.chain[0], Plain)
+
+ def test_set_result_with_empty_chain(self, astr_message_event):
+ """Test set_result handles empty chain correctly."""
+ result = MessageEventResult()
+ # chain is already an empty list by default
+ astr_message_event.set_result(result)
+
+ assert astr_message_event._result.chain == []
+
+
+class TestStopContinueEvent:
+ """Tests for stop_event and continue_event methods."""
+
+ def test_stop_event_creates_result_if_none(self, astr_message_event):
+ """Test stop_event creates result if none exists."""
+ astr_message_event.stop_event()
+
+ assert astr_message_event._result is not None
+ assert astr_message_event.is_stopped() is True
+
+ def test_stop_event_with_existing_result(self, astr_message_event):
+ """Test stop_event with existing result."""
+ astr_message_event.set_result(MessageEventResult().message("Test"))
+ astr_message_event.stop_event()
+
+ assert astr_message_event.is_stopped() is True
+
+ def test_continue_event_creates_result_if_none(self, astr_message_event):
+ """Test continue_event creates result if none exists."""
+ astr_message_event.continue_event()
+
+ assert astr_message_event._result is not None
+ assert astr_message_event.is_stopped() is False
+
+ def test_continue_event_with_existing_result(self, astr_message_event):
+ """Test continue_event with existing result."""
+ astr_message_event.set_result(MessageEventResult().message("Test"))
+ astr_message_event.stop_event()
+ astr_message_event.continue_event()
+
+ assert astr_message_event.is_stopped() is False
+
+ def test_is_stopped_default_false(self, astr_message_event):
+ """Test is_stopped returns False by default."""
+ assert astr_message_event.is_stopped() is False
+
+
+class TestIsPrivateChat:
+ """Tests for is_private_chat method."""
+
+ def test_is_private_chat_true(self, astr_message_event):
+ """Test is_private_chat returns True for friend message."""
+ assert astr_message_event.is_private_chat() is True
+
+ def test_is_private_chat_false(self, platform_meta, astrbot_message):
+ """Test is_private_chat returns False for group message."""
+ astrbot_message.type = MessageType.GROUP_MESSAGE
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.is_private_chat() is False
+
+
+class TestIsWakeUp:
+ """Tests for is_wake_up method."""
+
+ def test_is_wake_up_default_false(self, astr_message_event):
+ """Test is_wake_up returns False by default."""
+ assert astr_message_event.is_wake_up() is False
+
+ def test_is_wake_up_when_set(self, astr_message_event):
+ """Test is_wake_up returns True when is_wake is set."""
+ astr_message_event.is_wake = True
+ assert astr_message_event.is_wake_up() is True
+
+
+class TestIsAdmin:
+ """Tests for is_admin method."""
+
+ def test_is_admin_default_false(self, astr_message_event):
+ """Test is_admin returns False by default."""
+ assert astr_message_event.is_admin() is False
+
+ def test_is_admin_when_admin(self, astr_message_event):
+ """Test is_admin returns True when role is admin."""
+ astr_message_event.role = "admin"
+ assert astr_message_event.is_admin() is True
+
+
+class TestProcessBuffer:
+ """Tests for process_buffer method."""
+
+ @pytest.mark.asyncio
+ async def test_process_buffer_splits_by_pattern(self, astr_message_event):
+ """Test process_buffer splits buffer by pattern."""
+ buffer = "Line 1\nLine 2\nLine 3\nRemaining"
+ pattern = re.compile(r".*\n")
+
+ with patch.object(
+ astr_message_event, "send", new_callable=AsyncMock
+ ) as mock_send:
+ result = await astr_message_event.process_buffer(buffer, pattern)
+
+ # Should have sent 3 lines and remaining should be "Remaining"
+ assert mock_send.call_count == 3
+ assert result == "Remaining"
+
+ @pytest.mark.asyncio
+ async def test_process_buffer_no_match(self, astr_message_event):
+ """Test process_buffer returns original when no match."""
+ buffer = "No newlines here"
+ pattern = re.compile(r"\n")
+
+ result = await astr_message_event.process_buffer(buffer, pattern)
+
+ assert result == "No newlines here"
+
+
+class TestResultHelpers:
+ """Tests for result helper methods."""
+
+ def test_make_result(self, astr_message_event):
+ """Test make_result creates empty MessageEventResult."""
+ result = astr_message_event.make_result()
+ assert isinstance(result, MessageEventResult)
+
+ def test_plain_result(self, astr_message_event):
+ """Test plain_result creates result with text."""
+ result = astr_message_event.plain_result("Hello")
+
+ assert isinstance(result, MessageEventResult)
+ assert len(result.chain) == 1
+ assert isinstance(result.chain[0], Plain)
+ assert result.chain[0].text == "Hello"
+
+ def test_image_result_url(self, astr_message_event):
+ """Test image_result with URL."""
+ result = astr_message_event.image_result("http://example.com/image.jpg")
+
+ assert isinstance(result, MessageEventResult)
+ assert len(result.chain) == 1
+ assert isinstance(result.chain[0], Image)
+
+ def test_image_result_path(self, astr_message_event):
+ """Test image_result with file path."""
+ result = astr_message_event.image_result("/path/to/image.jpg")
+
+ assert isinstance(result, MessageEventResult)
+ assert len(result.chain) == 1
+ assert isinstance(result.chain[0], Image)
+
+
+class TestGetResult:
+ """Tests for get_result and clear_result methods."""
+
+ def test_get_result_returns_none_by_default(self, astr_message_event):
+ """Test get_result returns None by default."""
+ assert astr_message_event.get_result() is None
+
+ def test_get_result_returns_set_result(self, astr_message_event):
+ """Test get_result returns set result."""
+ result = MessageEventResult().message("Test")
+ astr_message_event.set_result(result)
+
+ assert astr_message_event.get_result() == result
+
+ def test_clear_result(self, astr_message_event):
+ """Test clear_result clears the result."""
+ astr_message_event.set_result(MessageEventResult().message("Test"))
+ astr_message_event.clear_result()
+
+ assert astr_message_event.get_result() is None
+
+
+class TestShouldCallLlm:
+ """Tests for should_call_llm method."""
+
+ def test_should_call_llm_default(self, astr_message_event):
+ """Test call_llm default is False."""
+ assert astr_message_event.call_llm is False
+
+ def test_should_call_llm_when_set(self, astr_message_event):
+ """Test should_call_llm sets call_llm."""
+ astr_message_event.should_call_llm(True)
+ assert astr_message_event.call_llm is True
+
+
+class TestRequestLlm:
+ """Tests for request_llm method."""
+
+ def test_request_llm_basic(self, astr_message_event):
+ """Test request_llm creates ProviderRequest."""
+ request = astr_message_event.request_llm(prompt="Hello")
+
+ assert request.prompt == "Hello"
+ assert request.session_id == ""
+ assert request.image_urls == []
+ assert request.contexts == []
+
+ def test_request_llm_with_all_params(self, astr_message_event):
+ """Test request_llm with all parameters."""
+ request = astr_message_event.request_llm(
+ prompt="Hello",
+ session_id="session123",
+ image_urls=["http://example.com/img.jpg"],
+ contexts=[{"role": "user", "content": "Hi"}],
+ system_prompt="You are helpful",
+ )
+
+ assert request.prompt == "Hello"
+ assert request.session_id == "session123"
+ assert request.image_urls == ["http://example.com/img.jpg"]
+ assert request.contexts == [{"role": "user", "content": "Hi"}]
+ assert request.system_prompt == "You are helpful"
+
+
+class TestSendStreaming:
+ """Tests for send_streaming method."""
+
+ @pytest.mark.asyncio
+ async def test_send_streaming_sets_has_send_oper(self, astr_message_event):
+ """Test send_streaming sets _has_send_oper flag."""
+ assert astr_message_event._has_send_oper is False
+
+ async def generator():
+ yield MessageEventResult().message("Test")
+
+ with patch(
+ "astrbot.core.platform.astr_message_event.Metric.upload",
+ new_callable=AsyncMock,
+ ):
+ await astr_message_event.send_streaming(generator())
+
+ assert astr_message_event._has_send_oper is True
+
+
+class TestSendTyping:
+ """Tests for send_typing method."""
+
+ @pytest.mark.asyncio
+ async def test_send_typing_default_empty(self, astr_message_event):
+ """Test send_typing default implementation is empty."""
+ # Should not raise any exception
+ await astr_message_event.send_typing()
+
+
+class TestReact:
+ """Tests for react method."""
+
+ @pytest.mark.asyncio
+ async def test_react_sends_emoji(self, astr_message_event):
+ """Test react sends emoji as message."""
+ with patch.object(
+ astr_message_event, "send", new_callable=AsyncMock
+ ) as mock_send:
+ await astr_message_event.react("👍")
+
+ mock_send.assert_called_once()
+ call_arg = mock_send.call_args[0][0]
+ # MessageChain is a dataclass with chain attribute
+ assert len(call_arg.chain) == 1
+ assert isinstance(call_arg.chain[0], Plain)
+ assert call_arg.chain[0].text == "👍"
+
+
+class TestGetGroup:
+ """Tests for get_group method."""
+
+ @pytest.mark.asyncio
+ async def test_get_group_returns_none_for_private(self, astr_message_event):
+ """Test get_group returns None for private chat."""
+ result = await astr_message_event.get_group()
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_get_group_with_group_id_param(self, astr_message_event):
+ """Test get_group with group_id parameter."""
+ # Default implementation returns None
+ result = await astr_message_event.get_group(group_id="group123")
+ assert result is None
+
+
+class TestMessageTypeHandling:
+ """Tests for message type handling edge cases."""
+
+ def test_message_type_from_valid_string(self, platform_meta):
+ """Valid MessageType string should be converted correctly."""
+ message = AstrBotMessage()
+ message.type = "FRIEND_MESSAGE"
+ message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.session.message_type == MessageType.FRIEND_MESSAGE
+ assert event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+ def test_message_type_from_invalid_string_defaults_to_friend(self, platform_meta):
+ """Invalid message type should default to FRIEND_MESSAGE."""
+ message = AstrBotMessage()
+ message.type = "InvalidMessageType"
+ message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.session.message_type == MessageType.FRIEND_MESSAGE
+ assert event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+ def test_message_type_from_none_defaults_to_friend(self, platform_meta):
+ """None message type should default to FRIEND_MESSAGE."""
+ message = AstrBotMessage()
+ message.type = None
+ message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.session.message_type == MessageType.FRIEND_MESSAGE
+ assert event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+ def test_message_type_from_integer_defaults_to_friend(self, platform_meta):
+ """Integer message type should default to FRIEND_MESSAGE."""
+ message = AstrBotMessage()
+ message.type = 123
+ message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.session.message_type == MessageType.FRIEND_MESSAGE
+ assert event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+
+class TestDefensiveGetattr:
+ """Tests for defensive getattr behavior in AstrMessageEvent."""
+
+ def test_get_messages_without_message_attr(self, astr_message_event):
+ """get_messages should handle message_obj without 'message' attribute."""
+ astr_message_event.message_obj = type("DummyMessage", (), {})()
+ messages = astr_message_event.get_messages()
+ assert isinstance(messages, list)
+
+ def test_get_message_type_without_type_attr(self, astr_message_event):
+ """get_message_type should handle message_obj without 'type' attribute."""
+ astr_message_event.message_obj = type("DummyMessage", (), {})()
+ message_type = astr_message_event.get_message_type()
+ assert isinstance(message_type, MessageType)
+
+ def test_get_sender_fields_without_sender_attr(self, astr_message_event):
+ """get_sender_id and get_sender_name should handle missing 'sender'."""
+ astr_message_event.message_obj = type("DummyMessage", (), {})()
+ sender_id = astr_message_event.get_sender_id()
+ sender_name = astr_message_event.get_sender_name()
+ assert isinstance(sender_id, str)
+ assert isinstance(sender_name, str)
+
+ def test_get_message_type_with_non_enum_type(self, astr_message_event):
+ """get_message_type should handle message_obj.type that is not a MessageType."""
+ class DummyMessage:
+ def __init__(self):
+ self.type = "not_an_enum"
+ self.message = []
+ astr_message_event.message_obj = DummyMessage()
+ message_type = astr_message_event.get_message_type()
+ assert isinstance(message_type, MessageType)
diff --git a/tests/unit/test_astrbot_message.py b/tests/unit/test_astrbot_message.py
new file mode 100644
index 000000000..508a2727b
--- /dev/null
+++ b/tests/unit/test_astrbot_message.py
@@ -0,0 +1,268 @@
+"""Tests for AstrBotMessage and MessageMember classes."""
+
+import time
+from unittest.mock import patch
+
+from astrbot.core.message.components import Image, Plain
+from astrbot.core.platform.astrbot_message import AstrBotMessage, Group, MessageMember
+from astrbot.core.platform.message_type import MessageType
+
+
+class TestMessageMember:
+ """Tests for MessageMember dataclass."""
+
+ def test_message_member_creation_basic(self):
+ """Test creating a MessageMember with required fields."""
+ member = MessageMember(user_id="user123")
+
+ assert member.user_id == "user123"
+ assert member.nickname is None
+
+ def test_message_member_creation_with_nickname(self):
+ """Test creating a MessageMember with nickname."""
+ member = MessageMember(user_id="user123", nickname="TestUser")
+
+ assert member.user_id == "user123"
+ assert member.nickname == "TestUser"
+
+ def test_message_member_str_with_nickname(self):
+ """Test __str__ method with nickname."""
+ member = MessageMember(user_id="user123", nickname="TestUser")
+ result = str(member)
+
+ assert "User ID: user123" in result
+ assert "Nickname: TestUser" in result
+
+ def test_message_member_str_without_nickname(self):
+ """Test __str__ method without nickname."""
+ member = MessageMember(user_id="user123")
+ result = str(member)
+
+ assert "User ID: user123" in result
+ assert "Nickname: N/A" in result
+
+
+class TestGroup:
+ """Tests for Group dataclass."""
+
+ def test_group_creation_basic(self):
+ """Test creating a Group with required fields."""
+ group = Group(group_id="group123")
+
+ assert group.group_id == "group123"
+ assert group.group_name is None
+ assert group.group_avatar is None
+ assert group.group_owner is None
+ assert group.group_admins is None
+ assert group.members is None
+
+ def test_group_creation_with_all_fields(self):
+ """Test creating a Group with all fields."""
+ members = [MessageMember(user_id="user1"), MessageMember(user_id="user2")]
+ group = Group(
+ group_id="group123",
+ group_name="Test Group",
+ group_avatar="http://example.com/avatar.jpg",
+ group_owner="owner123",
+ group_admins=["admin1", "admin2"],
+ members=members,
+ )
+
+ assert group.group_id == "group123"
+ assert group.group_name == "Test Group"
+ assert group.group_avatar == "http://example.com/avatar.jpg"
+ assert group.group_owner == "owner123"
+ assert group.group_admins == ["admin1", "admin2"]
+ assert group.members == members
+
+ def test_group_str_with_all_fields(self):
+ """Test __str__ method with all fields."""
+ members = [MessageMember(user_id="user1", nickname="User One")]
+ group = Group(
+ group_id="group123",
+ group_name="Test Group",
+ group_avatar="http://example.com/avatar.jpg",
+ group_owner="owner123",
+ group_admins=["admin1"],
+ members=members,
+ )
+ result = str(group)
+
+ assert "Group ID: group123" in result
+ assert "Name: Test Group" in result
+ assert "Avatar: http://example.com/avatar.jpg" in result
+ assert "Owner ID: owner123" in result
+ assert "Admin IDs: ['admin1']" in result
+ assert "Members Len: 1" in result
+
+ def test_group_str_with_minimal_fields(self):
+ """Test __str__ method with minimal fields."""
+ group = Group(group_id="group123")
+ result = str(group)
+
+ assert "Group ID: group123" in result
+ assert "Name: N/A" in result
+ assert "Avatar: N/A" in result
+ assert "Owner ID: N/A" in result
+ assert "Admin IDs: N/A" in result
+ assert "Members Len: 0" in result
+ assert "First Member: N/A" in result
+
+
+class TestAstrBotMessage:
+ """Tests for AstrBotMessage class."""
+
+ def test_astrbot_message_creation(self):
+ """Test creating an AstrBotMessage."""
+ message = AstrBotMessage()
+
+ assert message.group is None
+ assert message.timestamp is not None
+ assert isinstance(message.timestamp, int)
+
+ def test_astrbot_message_timestamp(self):
+ """Test timestamp is set on creation."""
+ with patch.object(time, "time", return_value=1234567890):
+ message = AstrBotMessage()
+ assert message.timestamp == 1234567890
+
+ def test_astrbot_message_all_attributes(self):
+ """Test setting all attributes on AstrBotMessage."""
+ message = AstrBotMessage()
+ message.type = MessageType.FRIEND_MESSAGE
+ message.self_id = "bot123"
+ message.session_id = "session123"
+ message.message_id = "msg123"
+ message.sender = MessageMember(user_id="user123", nickname="TestUser")
+ message.message = [Plain(text="Hello")]
+ message.message_str = "Hello"
+ message.raw_message = {"raw": "data"}
+
+ assert message.type == MessageType.FRIEND_MESSAGE
+ assert message.self_id == "bot123"
+ assert message.session_id == "session123"
+ assert message.message_id == "msg123"
+ assert message.sender.user_id == "user123"
+ assert len(message.message) == 1
+ assert message.message_str == "Hello"
+ assert message.raw_message == {"raw": "data"}
+
+ def test_astrbot_message_str(self):
+ """Test __str__ method."""
+ message = AstrBotMessage()
+ message.type = MessageType.FRIEND_MESSAGE
+ message.self_id = "bot123"
+
+ result = str(message)
+ assert "'type'" in result
+ assert "'self_id'" in result
+
+
+class TestAstrBotMessageGroupId:
+ """Tests for AstrBotMessage group_id property."""
+
+ def test_group_id_returns_empty_when_no_group(self):
+ """Test group_id returns empty string when group is None."""
+ message = AstrBotMessage()
+ assert message.group_id == ""
+
+ def test_group_id_returns_group_id_when_group_exists(self):
+ """Test group_id returns the group's id when group exists."""
+ message = AstrBotMessage()
+ message.group = Group(group_id="group123")
+
+ assert message.group_id == "group123"
+
+ def test_group_id_setter_creates_new_group(self):
+ """Test group_id setter creates a new group if none exists."""
+ message = AstrBotMessage()
+ message.group_id = "new_group123"
+
+ assert message.group is not None
+ assert message.group.group_id == "new_group123"
+
+ def test_group_id_setter_updates_existing_group(self):
+ """Test group_id setter updates existing group's id."""
+ message = AstrBotMessage()
+ message.group = Group(group_id="old_group")
+ message.group_id = "new_group"
+
+ assert message.group.group_id == "new_group"
+
+ def test_group_id_setter_with_none_removes_group(self):
+ """Test group_id setter with None removes the group."""
+ message = AstrBotMessage()
+ message.group = Group(group_id="group123")
+ message.group_id = None
+
+ assert message.group is None
+
+ def test_group_id_setter_with_empty_string_removes_group(self):
+ """Test group_id setter with empty string removes the group."""
+ message = AstrBotMessage()
+ message.group = Group(group_id="group123")
+ message.group_id = ""
+
+ assert message.group is None
+
+
+class TestAstrBotMessageTypes:
+ """Tests for AstrBotMessage with different message types."""
+
+ def test_friend_message_type(self):
+ """Test AstrBotMessage with FRIEND_MESSAGE type."""
+ message = AstrBotMessage()
+ message.type = MessageType.FRIEND_MESSAGE
+
+ assert message.type == MessageType.FRIEND_MESSAGE
+ assert message.type.value == "FriendMessage"
+
+ def test_group_message_type(self):
+ """Test AstrBotMessage with GROUP_MESSAGE type."""
+ message = AstrBotMessage()
+ message.type = MessageType.GROUP_MESSAGE
+
+ assert message.type == MessageType.GROUP_MESSAGE
+ assert message.type.value == "GroupMessage"
+
+ def test_other_message_type(self):
+ """Test AstrBotMessage with OTHER_MESSAGE type."""
+ message = AstrBotMessage()
+ message.type = MessageType.OTHER_MESSAGE
+
+ assert message.type == MessageType.OTHER_MESSAGE
+ assert message.type.value == "OtherMessage"
+
+
+class TestAstrBotMessageChain:
+ """Tests for AstrBotMessage message chain."""
+
+ def test_message_chain_with_plain_text(self):
+ """Test message chain with plain text."""
+ message = AstrBotMessage()
+ message.message = [Plain(text="Hello world")]
+
+ assert len(message.message) == 1
+ assert isinstance(message.message[0], Plain)
+ assert message.message[0].text == "Hello world"
+
+ def test_message_chain_with_multiple_components(self):
+ """Test message chain with multiple components."""
+ message = AstrBotMessage()
+ message.message = [
+ Plain(text="Hello "),
+ Plain(text="world"),
+ Image(file="http://example.com/img.jpg"),
+ ]
+
+ assert len(message.message) == 3
+ assert isinstance(message.message[0], Plain)
+ assert isinstance(message.message[1], Plain)
+ assert isinstance(message.message[2], Image)
+
+ def test_message_chain_empty(self):
+ """Test empty message chain."""
+ message = AstrBotMessage()
+ message.message = []
+
+ assert len(message.message) == 0
From a6009e2bd8cf89944cacb0a17c9d42d65fbf67eb Mon Sep 17 00:00:00 2001
From: LIghtJUNction
Date: Fri, 27 Feb 2026 22:06:40 +0800
Subject: [PATCH 02/12] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=88=B0=E4=B8=BB?=
=?UTF-8?q?=E5=88=86=E6=94=AF=20(#5537)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: add bocha web search tool (#4902)
* add bocha web search tool
* Revert "add bocha web search tool"
This reverts commit 1b36d75a17b4c4751828f31f6759357cd2d4000a.
* add bocha web search tool
* fix: correct temporary_cache spelling and update supported tools for web search
* ruff
---------
Co-authored-by: Soulter <905617992@qq.com>
* fix: messages[x] assistant content must contain at least one part (#4928)
* fix: messages[x] assistant content must contain at least one part
fixes: #4876
* ruff format
* chore: bump version to 4.14.5 (#4930)
* feat: implement feishu / lark media file handling utilities for file, audio and video processing (#4938)
* feat: implement media file handling utilities for audio and video processing
* feat: refactor file upload handling for audio and video in LarkMessageEvent
* feat: add cleanup for failed audio and video conversion outputs in media_utils
* feat: add utility methods for sending messages and uploading files in LarkMessageEvent
* fix: correct spelling of 'temporary' in SharedPreferences class
* perf: optimize webchat and wecom ai queue lifecycle (#4941)
* perf: optimize webchat and wecom ai queue lifecycle
* perf: enhance webchat back queue management with conversation ID support
* fix: localize provider source config UI (#4933)
* fix: localize provider source ui
* feat: localize provider metadata keys
* chore: add provider metadata translations
* chore: format provider i18n changes
* fix: preserve metadata fields in i18n conversion
* fix: internationalize platform config and dialog
* fix: add Weixin official account platform icon
---------
Co-authored-by: Soulter <905617992@qq.com>
* chore: bump version to 4.14.6
* feat: add provider-souce-level proxy (#4949)
* feat: 添加 Provider 级别代理支持及请求失败日志
* refactor: simplify provider source configuration structure
* refactor: move env proxy fallback logic to log_connection_failure
* refactor: update client proxy handling and add terminate method for cleanup
* refactor: update no_proxy configuration to remove redundant subnet
---------
Co-authored-by: Soulter <905617992@qq.com>
* feat(ComponentPanel): implement permission management for dashboard (#4887)
* feat(backend): add permission update api
* feat(useCommandActions): add updatePermission action and translations
* feat(dashboard): implement permission editing ui
* style: fix import sorting in command.py
* refactor(backend): extract permission update logic to service
* feat(i18n): add success and failure messages for command updates
---------
Co-authored-by: Soulter <905617992@qq.com>
* feat: 允许 LLM 预览工具返回的图片并自主决定是否发送 (#4895)
* feat: 允许 LLM 预览工具返回的图片并自主决定是否发送
* 复用 send_message_to_user 替代独立的图片发送工具
* feat: implement _HandleFunctionToolsResult class for improved tool response handling
* docs: add path handling guidelines to AGENTS.md
---------
Co-authored-by: Soulter <905617992@qq.com>
* feat(telegram): 添加媒体组(相册)支持 / add media group (album) support (#4893)
* feat(telegram): 添加媒体组(相册)支持 / add media group (album) support
## 功能说明
支持 Telegram 的媒体组消息(相册),将多张图片/视频合并为一条消息处理,而不是分散成多条消息。
## 主要改动
### 1. 初始化媒体组缓存 (__init__)
- 添加 `media_group_cache` 字典存储待处理的媒体组消息
- 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践)
- 最大等待时间 10 秒(防止永久等待)
### 2. 消息处理流程 (message_handler)
- 检测 `media_group_id` 判断是否为媒体组消息
- 媒体组消息走特殊处理流程,避免分散处理
### 3. 媒体组消息缓存 (handle_media_group_message)
- 缓存收到的媒体组消息
- 使用 APScheduler 实现防抖(debounce)机制
- 每收到新消息时重置超时计时器
- 超时后触发统一处理
### 4. 媒体组合并处理 (process_media_group)
- 从缓存中取出所有媒体项
- 使用第一条消息作为基础(保留文本、回复等信息)
- 依次添加所有图片、视频、文档到消息链
- 将合并后的消息发送到处理流程
## 技术方案论证
Telegram Bot API 在处理媒体组时的设计限制:
1. 将媒体组的每个消息作为独立的 update 发送
2. 每个 update 带有相同的 `media_group_id`
3. **不提供**组的总数、结束标志或一次性完整组的机制
因此,bot 必须自行收集消息,并通过硬编码超时(timeout/delay)等待可能延迟到达的消息。
这是目前唯一可靠的方案,被官方实现、主流框架和开发者社区广泛采用。
### 官方和社区证据:
- **Telegram Bot API 服务器实现(tdlib)**:明确指出缺少结束标志或总数信息
https://github.com/tdlib/telegram-bot-api/issues/643
- **Telegram Bot API 服务器 issue**:讨论媒体组处理的不便性,推荐使用超时机制
https://github.com/tdlib/telegram-bot-api/issues/339
- **Telegraf(Node.js 框架)**:专用媒体组中间件使用 timeout 控制等待时间
https://github.com/DieTime/telegraf-media-group
- **StackOverflow 讨论**:无法一次性获取媒体组所有文件,必须手动收集
https://stackoverflow.com/questions/50180048/telegram-api-get-all-uploaded-photos-by-media-group-id
- **python-telegram-bot 社区**:确认媒体组消息单独到达,需手动处理
https://github.com/python-telegram-bot/python-telegram-bot/discussions/3143
- **Telegram Bot API 官方文档**:仅定义 `media_group_id` 为可选字段,不提供获取完整组的接口
https://core.telegram.org/bots/api#message
## 实现细节
- 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践)
- 最大等待时间 10 秒(防止永久等待)
- 采用防抖(debounce)机制:每收到新消息重置计时器
- 利用 APScheduler 实现延迟处理和任务调度
## 测试验证
- ✅ 发送 5 张图片相册,成功合并为一条消息
- ✅ 保留原始文本说明和回复信息
- ✅ 支持图片、视频、文档混合的媒体组
- ✅ 日志显示 Processing media group with 5 items
## 代码变更
- 文件:astrbot/core/platform/sources/telegram/tg_adapter.py
- 新增代码:124 行
- 新增方法:handle_media_group_message(), process_media_group()
Co-Authored-By: Claude Sonnet 4.5
* refactor(telegram): 优化媒体组处理性能和可靠性
根据代码审查反馈改进:
1. 实现 media_group_max_wait 防止无限延迟
- 跟踪媒体组创建时间,超过最大等待时间立即处理
- 最坏情况下 10 秒内必定处理,防止消息持续到达导致无限延迟
2. 移除手动 job 查找优化性能
- 删除 O(N) 的 get_jobs() 循环扫描
- 依赖 replace_existing=True 自动替换任务
3. 重用 convert_message 减少代码重复
- 统一所有媒体类型转换逻辑
- 未来添加新媒体类型只需修改一处
Co-Authored-By: Claude Sonnet 4.5
* fix(telegram): handle missing message in media group processing and improve logging messages
---------
Co-authored-by: Ubuntu
Co-authored-by: Claude Sonnet 4.5
Co-authored-by: Soulter <905617992@qq.com>
* feat: add welcome feature with localized content and onboarding steps
* fix: correct height attribute to max-height for dialog component
* feat: supports electron app (#4952)
* feat: add desktop wrapper with frontend-only packaging
* docs: add desktop build docs and track dashboard lockfile
* fix: track desktop lockfile for npm ci
* fix: allow custom install directory for windows installer
* chore: migrate desktop workflow to pnpm
* fix(desktop): build AppImage only on Linux
* fix(desktop): harden packaged startup and backend bundling
* fix(desktop): adapt packaged restart and plugin dependency flow
* fix(desktop): prevent backend respawn race on quit
* fix(desktop): prefer pyproject version for desktop packaging
* fix(desktop): improve startup loading UX and reduce flicker
* ci: add desktop multi-platform release workflow
* ci: fix desktop release build and mac runner labels
* ci: disable electron-builder auto publish in desktop build
* ci: avoid electron-builder publish path in build matrix
* ci: normalize desktop release artifact names
* ci: exclude blockmap files from desktop release assets
* ci: prefix desktop release assets with AstrBot and purge blockmaps
* feat: add electron bridge types and expose backend control methods in preload script
* Update startup screen assets and styles
- Changed the icon from PNG to SVG format for better scalability.
- Updated the border color from #d0d0d0 to #eeeeee for a softer appearance.
- Adjusted the width of the startup screen from 460px to 360px for improved responsiveness.
* Update .gitignore to include package.json
* chore: remove desktop gitkeep ignore exceptions
* docs: update desktop troubleshooting for current runtime behavior
* refactor(desktop): modularize runtime and harden startup flow
---------
Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
* fix: dedupe preset messages (#4961)
* feat: enhance package.json with resource filters and compression settings
* chore: update Python version requirements to 3.12 (#4963)
* chore: bump version to 4.14.7
* feat: refactor release workflow and add special update handling for electron app (#4969)
* chore: bump version to 4.14.8 and bump faiss-cpu version up to date
* chore: auto ann fix by ruff (#4903)
* chore: auto fix by ruff
* refactor: 统一修正返回类型注解为 None/bool 以匹配实现
* refactor: 将 _get_next_page 改为异步并移除多余的请求错误抛出
* refactor: 将 get_client 的返回类型改为 object
* style: 为 LarkMessageEvent 的相关方法添加返回类型注解 None
---------
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
* fix: prepare OpenSSL via vcpkg for Windows ARM64
* ci: change ghcr namespace
* chore: update pydantic dependency version (#4980)
* feat: add delete button to persona management dialog (#4978)
* Initial plan
* feat: add delete button to persona management dialog
- Added delete button to PersonaForm dialog (only visible when editing)
- Implemented deletePersona method with confirmation dialog
- Connected delete event to PersonaManager for proper handling
- Button positioned on left side of dialog actions for clear separation
- Uses existing i18n translations for delete button and messages
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
* fix: use finally block to ensure saving state is reset
- Moved `this.saving = false` to finally block in deletePersona
- Ensures UI doesn't stay in saving state after errors
- Follows best practices for state management
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
* feat: enhance Dingtalk adapter with active push message and image, video, audio message type (#4986)
* fix: handle pip install execution in frozen runtime (#4985)
* fix: handle pip install execution in frozen runtime
* fix: harden pip subprocess fallback handling
* fix: collect certifi data in desktop backend build (#4995)
* feat: 企业微信应用 支持主动消息推送,并优化企微应用、微信公众号、微信客服音频相关的处理 (#4998)
* feat: 企业微信智能机器人支持主动消息推送以及发送视频、文件等消息类型支持 (#4999)
* feat: enhance WecomAIBotAdapter and WecomAIBotMessageEvent for improved streaming message handling (#5000)
fixes: #3965
* feat: enhance persona tool management and update UI localization for subagent orchestration (#4990)
* feat: enhance persona tool management and update UI localization for subagent orchestration
* fix: remove debug logging for final ProviderRequest in build_main_agent function
* perf: 稳定源码与 Electron 打包环境下的 pip 安装行为,并修复非 Electron 环境下点击 WebUI 更新按钮时出现跳转对话框的问题 (#4996)
* fix: handle pip install execution in frozen runtime
* fix: harden pip subprocess fallback handling
* fix: scope global data root to packaged electron runtime
* refactor: inline frozen runtime check for electron guard
* fix: prefer current interpreter for source pip installs
* fix: avoid resolving venv python symlink for pip
* refactor: share runtime environment detection utilities
* fix: improve error message when pip module is unavailable
* fix: raise ImportError when pip module is unavailable
* fix: preserve ImportError semantics for missing pip
* fix: 修复非electron app环境更新时仍然显示electron更新对话框的问题
---------
Co-authored-by: Soulter <905617992@qq.com>
* fix: 'HandoffTool' object has no attribute 'agent' (#5005)
* fix: 移动agent的位置到super().__init__之后
* add: 添加一行注释
* chore(deps): bump the github-actions group with 2 updates (#5006)
Bumps the github-actions group with 2 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/download-artifact](https://github.com/actions/download-artifact).
Updates `astral-sh/setup-uv` from 6 to 7
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7)
Updates `actions/download-artifact` from 6 to 7
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)
---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
dependency-version: '7'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/download-artifact
dependency-version: '7'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix: stabilize packaged runtime pip/ssl behavior and mac font fallback (#5007)
* fix: patch pip distlib finder for frozen electron runtime
* fix: use certifi CA bundle for runtime SSL requests
* fix: configure certifi CA before core imports
* fix: improve mac font fallback for dashboard text
* fix: harden frozen pip patch and unify TLS connector
* refactor: centralize dashboard CJK font fallback stacks
* perf: reuse TLS context and avoid repeated frozen pip patch
* refactor: bootstrap TLS setup before core imports
* fix: use async confirm dialog for provider deletions
* fix: replace native confirm dialogs in dashboard
- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.
- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.
- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.
* fix: capture runtime bootstrap logs after logger init
- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.
- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.
- Include concrete exception details for TLS bootstrap failures to improve diagnosis.
* fix: harden runtime bootstrap and unify confirm handling
- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.
- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.
- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.
* refactor: simplify runtime tls bootstrap and tighten confirm typing
* refactor: align ssl helper namespace and confirm usage
* fix: 修复 Windows 打包版后端重启失败问题 (#5009)
* fix: patch pip distlib finder for frozen electron runtime
* fix: use certifi CA bundle for runtime SSL requests
* fix: configure certifi CA before core imports
* fix: improve mac font fallback for dashboard text
* fix: harden frozen pip patch and unify TLS connector
* refactor: centralize dashboard CJK font fallback stacks
* perf: reuse TLS context and avoid repeated frozen pip patch
* refactor: bootstrap TLS setup before core imports
* fix: use async confirm dialog for provider deletions
* fix: replace native confirm dialogs in dashboard
- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.
- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.
- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.
* fix: capture runtime bootstrap logs after logger init
- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.
- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.
- Include concrete exception details for TLS bootstrap failures to improve diagnosis.
* fix: harden runtime bootstrap and unify confirm handling
- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.
- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.
- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.
* refactor: simplify runtime tls bootstrap and tighten confirm typing
* refactor: align ssl helper namespace and confirm usage
* fix: avoid frozen restart crash from multiprocessing import
* fix: include missing frozen dependencies for windows backend
* fix: use execv for stable backend reboot args
* Revert "fix: use execv for stable backend reboot args"
This reverts commit 9cc27becffeba0e117fea26aa5c2e1fe7afc6e36.
* Revert "fix: include missing frozen dependencies for windows backend"
This reverts commit 52554bea1fa61045451600c64447b7bf38cf6c92.
* Revert "fix: avoid frozen restart crash from multiprocessing import"
This reverts commit 10548645b0ba1e19b64194878ece478a48067959.
* fix: reset pyinstaller onefile env before reboot
* fix: unify electron restart path and tray-exit backend cleanup
* fix: stabilize desktop restart detection and frozen reboot args
* fix: make dashboard restart wait detection robust
* fix: revert dashboard restart waiting interaction tweaks
* fix: pass auth token for desktop graceful restart
* fix: avoid false failure during graceful restart wait
* fix: start restart waiting before electron restart call
* fix: harden restart waiting and reboot arg parsing
* fix: parse start_time as numeric timestamp
* fix: 修复app内重启异常,修复app内点击重启不能立刻提示重启,以及在后端就绪时及时刷新界面的问题 (#5013)
* fix: patch pip distlib finder for frozen electron runtime
* fix: use certifi CA bundle for runtime SSL requests
* fix: configure certifi CA before core imports
* fix: improve mac font fallback for dashboard text
* fix: harden frozen pip patch and unify TLS connector
* refactor: centralize dashboard CJK font fallback stacks
* perf: reuse TLS context and avoid repeated frozen pip patch
* refactor: bootstrap TLS setup before core imports
* fix: use async confirm dialog for provider deletions
* fix: replace native confirm dialogs in dashboard
- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.
- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.
- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.
* fix: capture runtime bootstrap logs after logger init
- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.
- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.
- Include concrete exception details for TLS bootstrap failures to improve diagnosis.
* fix: harden runtime bootstrap and unify confirm handling
- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.
- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.
- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.
* refactor: simplify runtime tls bootstrap and tighten confirm typing
* refactor: align ssl helper namespace and confirm usage
* fix: avoid frozen restart crash from multiprocessing import
* fix: include missing frozen dependencies for windows backend
* fix: use execv for stable backend reboot args
* Revert "fix: use execv for stable backend reboot args"
This reverts commit 9cc27becffeba0e117fea26aa5c2e1fe7afc6e36.
* Revert "fix: include missing frozen dependencies for windows backend"
This reverts commit 52554bea1fa61045451600c64447b7bf38cf6c92.
* Revert "fix: avoid frozen restart crash from multiprocessing import"
This reverts commit 10548645b0ba1e19b64194878ece478a48067959.
* fix: reset pyinstaller onefile env before reboot
* fix: unify electron restart path and tray-exit backend cleanup
* fix: stabilize desktop restart detection and frozen reboot args
* fix: make dashboard restart wait detection robust
* fix: revert dashboard restart waiting interaction tweaks
* fix: pass auth token for desktop graceful restart
* fix: avoid false failure during graceful restart wait
* fix: start restart waiting before electron restart call
* fix: harden restart waiting and reboot arg parsing
* fix: parse start_time as numeric timestamp
* fix: preserve windows frozen reboot argv quoting
* fix: align restart waiting with electron restart timing
* fix: tighten graceful restart and unmanaged kill safety
* chore: bump version to 4.15.0 (#5003)
* fix: add reminder for v4.14.8 users regarding manual redeployment due to a bug
* fix: harden plugin dependency loading in frozen app runtime (#5015)
* fix: compare plugin versions semantically in market updates
* fix: prioritize plugin site-packages for in-process pip
* fix: reload starlette from plugin target site-packages
* fix: harden plugin dependency import precedence in frozen runtime
* fix: improve plugin dependency conflict handling
* refactor: simplify plugin conflict checks and version utils
* fix: expand transitive plugin dependencies for conflict checks
* fix: recover conflicting plugin dependencies during module prefer
* fix: reuse renderer restart flow for tray backend restart
* fix: add recoverable plugin dependency conflict handling
* revert: remove plugin version comparison changes
* fix: add missing tray restart backend labels
* feat: adding support for media and quoted message attachments for feishu (#5018)
* docs: add AUR installation method (#4879)
* docs: sync system package manager installation instructions to all languages
* Update README.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update README.md
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* fix/typo
* refactor: update system package manager installation instructions for Arch Linux across multiple language README files
* feat: add installation command for AstrBot in multiple language README files
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
* fix(desktop): 为 Electron 与后端日志增加按大小轮转 (#5029)
* fix(desktop): rotate electron and backend logs
* refactor(desktop): centralize log rotation defaults and debug fs errors
* fix(desktop): harden rotation fs ops and buffer backend log writes
* refactor(desktop): extract buffered logger and reduce sync stat calls
* refactor(desktop): simplify rotation flow and harden logger config
* fix(desktop): make app logging async and flush-safe
* fix: harden app log path switching and debug-gated rotation errors
* fix: cap buffered log chunk size during path switch
* feat: add first notice feature with multilingual support and UI integration
* fix: 提升打包版桌面端启动稳定性并优化插件依赖处理 (#5031)
* fix(desktop): rotate electron and backend logs
* refactor(desktop): centralize log rotation defaults and debug fs errors
* fix(desktop): harden rotation fs ops and buffer backend log writes
* refactor(desktop): extract buffered logger and reduce sync stat calls
* refactor(desktop): simplify rotation flow and harden logger config
* fix(desktop): make app logging async and flush-safe
* fix: harden app log path switching and debug-gated rotation errors
* fix: cap buffered log chunk size during path switch
* fix: avoid redundant plugin reinstall and upgrade electron
* fix: stop webchat tasks cleanly and bind packaged backend to localhost
* fix: unify platform shutdown and await webchat listener cleanup
* fix: improve startup logs for dashboard and onebot listeners
* fix: revert extra startup service logs
* fix: harden plugin import recovery and webchat listener cleanup
* fix: pin dashboard ci node version to 24.13.0
* fix: avoid duplicate webchat listener cleanup on terminate
* refactor: clarify platform task lifecycle management
* fix: continue platform shutdown when terminate fails
* feat: temporary file handling and introduce TempDirCleaner (#5026)
* feat: temporary file handling and introduce TempDirCleaner
- Updated various modules to use `get_astrbot_temp_path()` instead of `get_astrbot_data_path()` for temporary file storage.
- Renamed temporary files for better identification and organization.
- Introduced `TempDirCleaner` to manage the size of the temporary directory, ensuring it does not exceed a specified limit by deleting the oldest files.
- Added configuration option for maximum temporary directory size in the dashboard.
- Implemented tests for `TempDirCleaner` to verify cleanup functionality and size management.
* ruff
* fix: close unawaited reset coroutine on early return (#5033)
When an OnLLMRequestEvent hook stops event propagation, the
reset_coro created by build_main_agent was never awaited, causing
a RuntimeWarning. Close the coroutine explicitly before returning.
Fixes #5032
Co-authored-by: Limitless2023
* fix: update error logging message for connection failures
* docs: clean and sync README (#5014)
* fix: close missing div in README
* fix: sync README_zh-TW with README
* fix: sync README
* fix: correct typo
correct url in README_en README_fr README_ru
* docs: sync README_en with README
* Update README_en.md
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
* fix: provider extra param dialog key display error
* chore: ruff format
* feat: add send_chat_action for Telegram platform adapter (#5037)
* feat: add send_chat_action for Telegram platform adapter
Add typing/upload indicator when sending messages via Telegram.
- Added _send_chat_action helper method for sending chat actions
- Send appropriate action (typing, upload_photo, upload_document, upload_voice)
before sending different message types
- Support streaming mode with typing indicator
- Support supergroup with message_thread_id
* refactor(telegram): extract chat action helpers and add throttling
- Add ACTION_BY_TYPE mapping for message type to action priority
- Add _get_chat_action_for_chain() to determine action from message chain
- Add _send_media_with_action() for upload → send → restore typing pattern
- Add _ensure_typing() helper for typing status
- Add chat action throttling (0.5s) in streaming mode to avoid rate limits
- Update type annotation to ChatAction | str for better static checking
* feat(telegram): implement send_typing method for Telegram platform
---------
Co-authored-by: Soulter <905617992@qq.com>
* fix: 修复更新日志、官方文档弹窗双滚动条问题 (#5060)
* docs: sync and fix readme typo (#5055)
* docs: fix index typo
* docs: fix typo in README_en.md
- 移除英文README中意外出现的俄语,并替换为英语
* docs: fix html typo
- remove unused ''
* docs: sync table with README
* docs: sync README header format
- keep the README header format consistent
* doc: sync key features
* style: format files
- Fix formatting issues from previous PR
* fix: correct md anchor link
* docs: correct typo in README_fr.md
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* docs: correct typo in README_zh-TW.md
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* fix: 修复备份时缺失的人格文件夹映射 (#5042)
* feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件 (#5066)
* feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件
* feat: enhance QQOfficialWebhook to remember session scenes for group, channel, and friend messages
* perf: 优化分段回复间隔时间的初始化逻辑 (#5068)
fixes: #5059
* fix: chunk err when using openrouter deepseek (#5069)
* feat: add i18n supports for custom platform adapters (#5045)
* Feat: 为插件提供的适配器的元数据&i18n提供数据通路
* chore: update docstrings with pull request references
Added references to pull request 5045 in docstrings.
---------
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
* fix: 完善转发引用解析与图片回退并支持配置化控制 (#5054)
* feat: support fallback image parsing for quoted messages
* fix: fallback parse quoted images when reply chain has placeholders
* style: format network utils with ruff
* test: expand quoted parser coverage and improve fallback diagnostics
* fix: fallback to text-only retry when image requests fail
* fix: tighten image fallback and resolve nested quoted forwards
* refactor: simplify quoted message extraction and dedupe images
* fix: harden quoted parsing and openai error candidates
* fix: harden quoted image ref normalization
* refactor: organize quoted parser settings and logging
* fix: cap quoted fallback images and avoid retry loops
* refactor: split quoted message parser into focused modules
* refactor: share onebot segment parsing logic
* refactor: unify quoted message parsing flow
* feat: move quoted parser tuning to provider settings
* fix: add missing i18n metadata for quoted parser settings
* chore: refine forwarded message setting labels
* fix: add config tabs and routing for normal and system configurations
* chore: bump version to 4.16.0 (#5074)
* feat: add LINE platform support with adapter and configuration (#5085)
* fix-correct-FIRST_NOTICE.md-locale-path-resolution (#5083) (#5082)
* fix:修改配置文件目录
* fix:添加备选的FIRST_NOTICE.zh-CN.md用于兼容
* fix: remove unnecessary frozen flag from requirements export in Dockerfile
fixes: #5089
* fix #5089: add uv lock step in Dockerfile before export (#5091)
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
* feat: support hot reload after plugin load failure (#5043)
* add :Support hot reload after plugin load failure
* Apply suggestions from code review
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* fix:reformat code
* fix:reformat code
---------
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* feat: add fallback chat model chain in tool loop runner (#5109)
* feat: implement fallback provider support for chat models and update configuration
* feat: enhance provider selection display with count and chips for selected providers
* feat: update fallback chat providers to use provider settings and add warning for non-list fallback models
* feat: add Afdian support card to resources section in WelcomePage
* feat: replace colorlog with loguru for enhanced logging support (#5115)
* feat: add SSL configuration options for WebUI and update related logging (#5117)
* chore: bump version to 4.17.0
* fix: handle list format content from OpenAI-compatible APIs (#5128)
* fix: handle list format content from OpenAI-compatible APIs
Some LLM providers (e.g., GLM-4.5V via SiliconFlow) return content as
list[dict] format like [{'type': 'text', 'text': '...'}] instead of
plain string. This causes the raw list representation to be displayed
to users.
Changes:
- Add _normalize_content() helper to extract text from various content formats
- Use json.loads instead of ast.literal_eval for safer parsing
- Add size limit check (8KB) before attempting JSON parsing
- Only convert lists that match OpenAI content-part schema (has 'type': 'text')
to avoid collapsing legitimate list-literal replies like ['foo', 'bar']
- Add strip parameter to preserve whitespace in streaming chunks
- Clean up orphan tags that may leak from some models
Fixes #5124
* fix: improve content normalization safety
- Try json.loads first, fallback to ast.literal_eval for single-quoted
Python literals to avoid corrupting apostrophes (e.g., "don't")
- Coerce text values to str to handle null or non-string text fields
* fix: update retention logic in LogManager to handle backup count correctly
* chore: bump version to 4.17.1
* docs: Added instructions for deploying AstrBot using AstrBot Launcher. (#5136)
Added instructions for deploying AstrBot using AstrBot Launcher.
* fix: add MCP tools to function tool set in _plugin_tool_fix (#5144)
* fix: add support for collecting data from builtin stars in electron pyinstaller build (#5145)
* chore: bump version to 4.17.1
* chore: ruff format
* fix: prevent updates for AstrBot launched via launcher
* fix(desktop): include runtime deps for builtin plugins in backend build (#5146)
* fix: 'Plain' object has no attribute 'text' when using python 3.14 (#5154)
* fix: enhance plugin metadata handling by injecting attributes before instantiation (#5155)
* fix: enhance handle_result to support event context and webchat image sending
* chore: bump version to 4.17.3
* chore: ruff format
* feat: add NVIDIA provider template (#5157)
fixes: #5156
* feat: enhance provider sources panel with styled menu and mobile support
* fix: improve permission denied message for local execution in Python and shell tools
* feat: enhance PersonaForm component with responsive design and improved styling (#5162)
fix: #5159
* ui(CronJobPage): fix action column buttons overlapping in CronJobPage (#5163)
- 修改前:操作列容器仅使用 `d-flex`,在页面宽度变窄时,子元素(开关和删除按钮)会因为宽度挤压而发生视觉重叠,甚至堆叠在一起。
- 修改后:
1. 为容器添加了 `flex-nowrap`,强制禁止子元素换行。
2. 设置了 `min-width: 140px`,确保该列拥有固定的保护空间,防止被其他长文本列挤压。
3. 增加了 `gap: 12px` 间距,提升了操作辨识度并优化了点击体验。
* feat: add unsaved changes notice to configuration page and update messages
* feat: implement search functionality in configuration components and update UI (#5168)
* feat: add FAQ link to vertical sidebar and update navigation for localization
* feat: add announcement section to WelcomePage and localize announcement title
* chore: bump version to 4.17.4
* feat: supports send markdown message in qqofficial (#5173)
* feat: supports send markdown message in qqofficial
closes: #1093 #918 #4180 #4264
* ruff format
* fix: prevent duplicate error message when all LLM providers fail (#5183)
* fix: 修复选择配置文件进入配置文件管理弹窗直接关闭弹窗显示的配置文件不正确 (#5174)
* feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage (#5190)
* feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage
* feat: update random plugin selection logic to use pluginMarketData and refresh on relevant events
* feat: supports aihubmix
* docs: update readme
* chore: ruff format
* feat: add LINE support to multiple language README files
* feat(core): add plugin error hook for custom error routing (#5192)
* feat(core): add plugin error hook for custom error routing
* fix(core): align plugin error suppression with event stop state
* refactor: extract Voice_messages_forbidden fallback into shared helper with typed BadRequest exception (#5204)
- Add _send_voice_with_fallback helper to deduplicate voice forbidden handling
- Catch telegram.error.BadRequest instead of bare Exception with string matching
- Add text field to Record component to preserve TTS source text
- Store original text in Record during TTS conversion for use as document caption
- Skip _send_chat_action when chat_id is empty to avoid unnecessary warnings
* chore: bump version to 4.17.5
* feat: add admin permission checks for Python and Shell execution (#5214)
* fix: 改进微信公众号被动回复处理机制,引入缓冲与分片回复,并优化超时行为 (#5224)
* 修复wechat official 被动回复功能
* ruff format
---------
Co-authored-by: Soulter <905617992@qq.com>
* fix: 修复仅发送 JSON 消息段时的空消息回复报错 (#5208)
* Fix Register_Stage
· 补全 JSON 消息判断,修复发送 JSON 消息时遇到 “消息为空,跳过发送阶段” 的问题。
· 顺带补全其它消息类型判断。
Co-authored-by: Pizero
* Fix formatting and comments in stage.py
* Format stage.py
---------
Co-authored-by: Pizero
* docs: update related repo links
* fix(core): terminate active events on reset/new/del to prevent stale responses (#5225)
* fix(core): terminate active events on reset/new/del to prevent stale responses
Closes #5222
* style: fix import sorting in scheduler.py
* chore: remove Electron desktop pipeline and switch to tauri repo (#5226)
* ci: remove Electron desktop build from release pipeline
* chore: remove electron desktop and switch to tauri release trigger
* ci: remove desktop workflow dispatch trigger
* refactor: migrate data paths to astrbot_path helpers
* fix: point desktop update prompt to AstrBot-desktop releases
* fix: update feature request template for clarity and consistency in English and Chinese
* Feat/config leave confirm (#5249)
* feat: 配置文件增加未保存提示弹窗
* fix: 移除unsavedChangesDialog插件使用组件方式实现弹窗
* feat: add support for plugin astrbot-version and platform requirement checks (#5235)
* feat: add support for plugin astrbot-version and platform requirement checks
* fix: remove unsupported platform and version constraints from metadata.yaml
* fix: remove restriction on 'v' in astrbot_version specification format
* ruff format
* feat: add password confirmation when changing password (#5247)
* feat: add password confirmation when changing password
Fixes #5177
Adds a password confirmation field to prevent accidental password typos.
Changes:
- Backend: validate confirm_password matches new_password
- Frontend: add confirmation input with validation
- i18n: add labels and error messages for password mismatch
Co-Authored-By: Claude Sonnet 4.6
* fix(auth): improve error message for password confirmation mismatch
* fix(auth): update password hashing logic and improve confirmation validation
---------
Co-authored-by: whatevertogo
Co-authored-by: Claude Sonnet 4.6
* fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题 (#5250)
* fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题
修复 _normalize_content 函数未处理 dict 类型 content 的问题。
当 LLM 返回 {"type": "text", "text": "..."} 格式的 content 时,
现在会正确提取 text 字段而非直接转为字符串。
同时改进 fallback 行为,对 None 值返回空字符串。
Fixes #5244
* Update warning message for unexpected dict format
---------
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
* chore: remove outdated heihe.md documentation file
* fix: all mcp tools exposed to main agent (#5252)
* fix: enhance PersonaForm layout and improve tool selection display
* fix: update tool status display and add localization for inactive tools
* fix: remove additionalProperties from tool schema properties (#5253)
fixes: #5217
* fix: simplify error messages for account edit validation
* fix: streamline error response for empty new username and password in account edit
* chore: bump vertion to 4.17.6
* feat: add OpenRouter provider support and icon
* chore: ruff format
* refactor(dashboard): replace legacy isElectron bridge fields with isDesktop (#5269)
* refactor dashboard desktop bridge fields from isElectron to isDesktop
* refactor dashboard runtime detection into shared helper
* fix: update contributor avatar image URL to include max size and columns (#5268)
* feat: astrbot http api (#5280)
* feat: astrbot http api
* Potential fix for code scanning alert no. 34: Use of a broken or weak cryptographic hashing algorithm on sensitive data
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fix: improve error handling for missing attachment path in file upload
* feat: implement paginated retrieval of platform sessions for creators
* feat: refactor attachment directory handling in ChatRoute
* feat: update API endpoint paths for file and message handling
* feat: add documentation link to API key management section in settings
* feat: update API key scopes and related configurations in API routes and tests
* feat: enhance API key expiration options and add warning for permanent keys
* feat: add UTC normalization and serialization for API key timestamps
* feat: implement chat session management and validation for usernames
* feat: ignore session_id type chunks in message processing
---------
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* feat(dashboard): improve plugin platform support display and mobile accessibility (#5271)
* feat(dashboard): improve plugin platform support display and mobile accessibility
- Replace hover-based tooltips with interactive click menus for platform support information.
- Fix mobile touch issues by introducing explicit state control for status capsules.
- Enhance UI aesthetics with platform-specific icons and a structured vertical list layout.
- Add dynamic chevron icons to provide clear visual cues for expandable content.
* refactor(dashboard): refactor market card with computed properties for performance
* refactor(dashboard): unify plugin platform support UI with new reusable chip component
- Create shared 'PluginPlatformChip' component to encapsulate platform meta display.
- Fix mobile interaction bugs by simplifying menu triggers and event handling.
- Add stacked platform icon previews and dynamic chevron indicators within capsules.
- Improve information hierarchy using structured vertical lists for platform details.
- Optimize rendering efficiency with computed properties across both card views.
* fix: qq official guild message send error (#5287)
* fix: qq official guild message send error
* Update astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---------
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* 更新readme文档,补充桌面app说明,并向前移动位置 (#5297)
* docs: update desktop deployment section in README
* docs: refine desktop and launcher deployment descriptions
* Update README.md
* feat: add Anthropic Claude Code OAuth provider and adaptive thinking support (#5209)
* feat: add Anthropic Claude Code OAuth provider and adaptive thinking support
* fix: add defensive guard for metadata overrides and align budget condition with docs
* refactor: adopt sourcery-ai suggestions for OAuth provider
- Use use_api_key=False in OAuth subclass to avoid redundant
API-key client construction before replacing with auth_token client
- Generalize metadata override helper to merge all dict keys
instead of only handling 'limit', improving extensibility
* Feat/telegram command alias register #5233 (#5234)
* feat: support registering command aliases for Telegram
Now when registering commands with aliases, all aliases will be
registered as Telegram bot commands in addition to the main command.
Example:
@register_command(command_name="draw", alias={"画", "gen"})
Now /draw, /画, and /gen will all appear in the Telegram command menu.
* feat(telegram): add duplicate command name warning when registering commands
Log a warning when duplicate command names are detected during Telegram
command registration to help identify configuration conflicts.
* refactor: remove Anthropic OAuth provider implementation and related metadata overrides
* fix: 修复新建对话时因缺少会话ID导致配置绑定失败的问题 (#5292)
* fix:尝试修改
* fix:添加详细日志
* fix:进行详细修改,并添加日志
* fix:删除所有日志
* fix: 增加安全访问函数
- 给 localStorage 访问加了 try/catch + 可用性判断:dashboard/src/utils/chatConfigBinding.ts:13
- 新增 getFromLocalStorage/setToLocalStorage(在受限存储/无痕模式下异常时回退/忽略)
- getStoredDashboardUsername() / getStoredSelectedChatConfigId() 改为走安全读取:dashboard/src/utils/chatConfigBinding.ts:36 - 新增 setStoredSelectedChatConfigId(),写入失败静默忽略:dashboard/src/utils/chatConfigBinding.ts:44
- 把 ConfigSelector.vue 里直接 localStorage.getItem/setItem 全部替换为上述安全方法:dashboard/src/components/chat/ConfigSelector.vue:81
- 已重新跑过 pnpm run typecheck,通过。
* rm:删除个人用的文档文件
* Revert "rm:删除个人用的文档文件"
This reverts commit 0fceee05434cfbcb11e45bb967a77d5fa93196bf.
* rm:删除个人用的文档文件
* rm:删除个人用的文档文件
* chore: bump version to 4.18.0
* fix(SubAgentPage): 当中间的介绍文本非常长时,Flex 布局会自动挤压右侧的控制按钮区域 (#5306)
* fix: 修复新版本插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 (#5309)
* fix(ExtensionCard): 解决插件卡片大小不统一的问题
* fix(MarketPluginCard): 解决插件市场不加载插件的问题 (#5303)
* feat: supports spawn subagent as a background task that not block the main agent workflow (#5081)
* feat:为subagent添加后台任务参数
* ruff
* fix: update terminology from 'handoff mission' to 'background task' and refactor related logic
* fix: update terminology from 'background_mission' to 'background_task' in HandoffTool and related logic
* fix(HandoffTool): update background_task description for clarity on usage
---------
Co-authored-by: Soulter <905617992@qq.com>
* cho
* fix: 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 (#5316)
* chore: ruff format
* fix: remove hard-coded 6s timeout from tavily request
* fix: remove changelogs directory from .dockerignore
* feat(dashboard): make release redirect base URL configurable (#5330)
* feat(dashboard): make desktop release base URL configurable
* refactor(dashboard): use generic release base URL env with upstream default
* fix(dashboard): guard release base URL normalization when env is unset
* refactor(dashboard): use generic release URL helpers and avoid latest suffix duplication
* feat: add stop functionality for active agent sessions and improve handling of stop requests (#5380)
* feat: add stop functionality for active agent sessions and improve handling of stop requests
* feat: update stop button icon and tooltip in ChatInput component
* fix: correct indentation in tool call handling within ChatRoute class
* fix: chatui cannot persist file segment (#5386)
* fix(plugin): update plugin directory handling for reserved plugins (#5369)
* fix(plugin): update plugin directory handling for reserved plugins
* fix(plugin): add warning logs for missing plugin name, object, directory, and changelog
* chore(README): updated with README.md (#5375)
* chore(README): updated with README.md
* Update README_fr.md
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* Update README_zh-TW.md
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
* feat: add image urls / paths supports for subagent (#5348)
* fix: 修复5081号PR在子代理执行后台任务时,未正确使用系统配置的流式/非流请求的问题(#5081)
* feat:为子代理增加远程图片URL参数支持
* fix: update description for image_urls parameter in HandoffTool to clarify usage in multimodal tasks
* ruff format
---------
Co-authored-by: Soulter <905617992@qq.com>
* feat: add hot reload when failed to load plugins (#5334)
* feat:add hot reload when failed to load plugins
* apply bot suggestions
* fix(chatui): add copy rollback path and error message. (#5352)
* fix(chatui): add copy rollback path and error message.
* fix(chatui): fixed textarea leak in the copy button.
* fix(chatui): use color styles from the component library.
* fix: 处理配置文件中的 UTF-8 BOM 编码问题 (#5376)
* fix(config): handle UTF-8 BOM in configuration file loading
Problem:
On Windows, some text editors (like Notepad) automatically add UTF-8 BOM
to JSON files when saving. This causes json.decoder.JSONDecodeError:
"Unexpected UTF-8 BOM" and AstrBot fails to start when cmd_config.json
contains BOM.
Solution:
Add defensive check to strip UTF-8 BOM (\ufeff) if present before
parsing JSON configuration file.
Impact:
- Improves robustness and cross-platform compatibility
- No breaking changes to existing functionality
- Fixes startup failure when configuration file has UTF-8 BOM encoding
Relates-to: Windows editor compatibility issues
* style: fix code formatting with ruff
Fix single quote to double quote to comply with project code style.
* feat: add plugin load&unload hook (#5331)
* 添加了插件的加载完成和卸载完成的钩子事件
* 添加了插件的加载完成和卸载完成的钩子事件
* format code with ruff
* ruff format
---------
Co-authored-by: Soulter <905617992@qq.com>
* test: enhance test framework with comprehensive fixtures and mocks (#5354)
* test: enhance test framework with comprehensive fixtures and mocks
- Add shared mock builders for aiocqhttp, discord, telegram
- Add test helpers for platform configs and mock objects
- Expand conftest.py with test profile support
- Update coverage test workflow configuration
Co-Authored-By: Claude Sonnet 4.6
* refactor(tests): 移动并重构模拟 LLM 响应和消息组件函数
* fix(tests): 优化 pytest_runtest_setup 中的标记检查逻辑
---------
Co-authored-by: whatevertogo
Co-authored-by: Claude Sonnet 4.6
* test: add comprehensive tests for message event handling (#5355)
* test: add comprehensive tests for message event handling
- Add AstrMessageEvent unit tests (688 lines)
- Add AstrBotMessage unit tests
- Enhance smoke tests with message event scenarios
Co-Authored-By: Claude Sonnet 4.6
* fix: improve message type handling and add defensive tests
---------
Co-authored-by: whatevertogo
Co-authored-by: Claude Sonnet 4.6
* feat: add support for showing tool call results in agent execution (#5388)
closes: #5329
* fix: resolve pipeline and star import cycles (#5353)
* fix: resolve pipeline and star import cycles
- Add bootstrap.py and stage_order.py to break circular dependencies
- Export Context, PluginManager, StarTools from star module
- Update pipeline __init__ to defer imports
- Split pipeline initialization into separate bootstrap module
Co-Authored-By: Claude Sonnet 4.6
* fix: add logging for get_config() failure in Star class
* fix: reorder logger initialization in base.py
---------
Co-authored-by: whatevertogo
Co-authored-by: Claude Sonnet 4.6
* feat: enable computer-use tools for subagent handoff (#5399)
* fix: enforce admin guard for sandbox file transfer tools (#5402)
* fix: enforce admin guard for sandbox file transfer tools
* refactor: deduplicate computer tools admin permission checks
* fix: add missing space in permission error message
* fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 (#5391)
* fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性
原因 (Necessity):
1. 内核一致性:AstrBot 内核的 Record 和 Video 组件均具备识别 `file:///` 协议头的逻辑,但 File 组件此前缺失此功能,导致行为不统一。
2. OneBot 协议合规:OneBot 11 标准要求本地文件路径必须使用 `file:///` 协议头。此前驱动层未对裸路径进行自动转换,导致发送本地文件时常触发 retcode 1200 (识别URL失败) 错误。
3. 容器环境适配:在 Docker 等路径隔离环境下,裸路径更容易因驱动或协议端的解析歧义而失效。
更改 (Changes):
- [astrbot/core/message/components.py]:
- 在 File.get_file() 中增加对 `file:///` 前缀的识别与剥离逻辑,使其与 Record/Video 组件行为对齐。
- [astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py]:
- 在发送文件前增加自动修正逻辑:若路径为绝对路径且未包含协议头,驱动层将自动补全 `file:///` 前缀。
- 对 http、base64 及已有协议头,确保不干扰原有的正常传输逻辑。
影响 (Impact):
- 以完全兼容的方式增强了文件发送的鲁棒性。
- 解决了插件在发送日志等本地生成的压缩包时,因路径格式不规范导致的发送失败问题。
* refactor(core): 根据 cr 建议,规范化文件 URI 生成与解析逻辑,优化跨平台兼容性
原因 (Necessity):
1. 修复原生路径与 URI 转换在 Windows 下的不对称问题。
2. 规范化 file: 协议头处理,确保符合 RFC 标准并能在 Linux/Windows 间稳健切换。
3. 增强协议判定准确度,防止对普通绝对路径的误处理。
更改 (Changes):
- [astrbot/core/platform/sources/aiocqhttp]:
- 弃用手动拼接,改用 `pathlib.Path.as_uri()` 生成标准 URI。
- 将协议检测逻辑从前缀匹配优化为包含性检测 ("://")。
- [astrbot/core/message/components]:
- 重构 `File.get_file` 解析逻辑,支持对称处理 2/3 斜杠格式。
- 针对 Windows 环境增加了对 `file:///C:/` 格式的自动修正,避免 `os.path` 识别失效。
- [data/plugins/astrbot_plugin_logplus]:
- 在直接 API 调用中同步应用 URI 规范化处理。
影响 (Impact):
- 解决 Docker 环境中因路径不规范导致的 "识别URL失败" 报错。
- 提升了本体框架在 Windows 系统下的文件操作鲁棒性。
* i18n(SubAgentPage): complete internationalization for subagent orchestration page (#5400)
* i18n: complete internationalization for subagent orchestration page
- Replace hardcoded English strings in [SubAgentPage.vue] with i18n keys.
- Update `en-US` and `zh-CN` locales with missing hints, validation messages, and empty state translations.
- Fix translation typos and improve consistency across the SubAgent orchestration UI.
* fix(bug_risk): 避免在模板中的翻译调用上使用 || 'Close' 作为回退值。
* fix(aiocqhttp): enhance shutdown process for aiocqhttp adapter (#5412)
* fix: pass embedding dimensions to provider apis (#5411)
* fix(context): log warning when platform not found for session
* fix(context): improve logging for platform not found in session
* chore: bump version to 4.18.2
* chore: bump version to 4.18.2
* chore: bump version to 4.18.2
* fix: Telegram voice message format (OGG instead of WAV) causing issues with OpenAI STT API (#5389)
* chore: ruff format
* feat(dashboard): add generic desktop app updater bridge (#5424)
* feat(dashboard): add generic desktop app updater bridge
* fix(dashboard): address updater bridge review feedback
* fix(dashboard): unify updater bridge types and error logging
* fix(dashboard): consolidate updater bridge typings
* fix(conversation): retain existing persona_id when updating conversation
* fix(dashboard): 修复设置页新建 API Key 后复制失败问题 (#5439)
* Fix: GitHub proxy not displaying correctly in WebUI (#5438)
* fix(dashboard): preserve custom GitHub proxy setting on reload
* fix(dashboard): keep github proxy selection persisted in settings
* fix(persona): enhance persona resolution logic for conversations and sessions
* fix: ensure tool call/response pairing in context truncation (#5417)
* fix: ensure tool call/response pairing in context truncation
* refactor: simplify fix_messages to single-pass state machine
* perf(cron): enhance future task session isolation
fixes: #5392
* feat: add useExtensionPage composable for managing plugin extensions
- Implemented a new composable `useExtensionPage` to handle various functionalities related to plugin management, including fetching extensions, handling updates, and managing UI states.
- Added support for conflict checking, plugin installation, and custom source management.
- Integrated search and filtering capabilities for plugins in the market.
- Enhanced user experience with dialogs for confirmations and notifications.
- Included pagination and sorting features for better plugin visibility.
* fix: clear markdown field when sending media messages via QQ Official Platform (#5445)
* fix: clear markdown field when sending media messages via QQ Official API
* refactor: use pop() to remove markdown key instead of setting None
* fix: cannot automatically get embedding dim when create embedding provider (#5442)
* fix(dashboard): 强化 API Key 复制临时节点清理逻辑
* fix(embedding): 自动检测改为探测 OpenAI embedding 最大可用维度
* fix: normalize openai embedding base url and add hint key
* i18n: add embedding_api_base hint translations
* i18n: localize provider embedding/proxy metadata hints
* fix: show provider-specific embedding API Base URL hint as field subtitle
* fix(embedding): cap OpenAI detect_dim probes with early short-circuit
* fix(dashboard): return generic error on provider adapter import failure
* 回退检测逻辑
* fix: 修复Pyright静态类型检查报错 (#5437)
* refactor: 修正 Sqlite 查询、下载回调、接口重构与类型调整
* feat: 为 OneBotClient 增加 CallAction 协议与异步调用支持
* fix(telegram): avoid duplicate message_thread_id in streaming (#5430)
* perf: batch metadata query in KB retrieval to fix N+1 problem (#5463)
* perf: batch metadata query in KB retrieval to fix N+1 problem
Replace N sequential get_document_with_metadata() calls with a single
get_documents_with_metadata_batch() call using SQL IN clause.
Benchmark results (local SQLite):
- 10 docs: 10.67ms → 1.47ms (7.3x faster)
- 20 docs: 26.00ms → 2.68ms (9.7x faster)
- 50 docs: 63.87ms → 2.79ms (22.9x faster)
* refactor: use set[str] param type and chunk IN clause for SQLite safety
Address review feedback:
- Change doc_ids param from list[str] to set[str] to avoid unnecessary conversion
- Chunk IN clause into batches of 900 to stay under SQLite's 999 parameter limit
- Remove list() wrapping at call site, pass set directly
* fix:fix the issue where incomplete cleanup of residual plugins occurs… (#5462)
* fix:fix the issue where incomplete cleanup of residual plugins occurs in the failed loading of plugins
* fix:ruff format,apply bot suggestions
* Apply suggestion from @gemini-code-assist[bot]
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* chore: 为类型检查添加 TYPE_CHECKING 的导入与阶段类型引用 (#5474)
* fix(line): line adapter does not appear in the add platform dialog
fixes: #5477
* [bug]查看介入教程line前往错误界面的问题 (#5479)
Fixes #5478
* chore: bump version to 4.18.3
* feat: implement follow-up message handling in ToolLoopAgentRunner (#5484)
* feat: implement follow-up message handling in ToolLoopAgentRunner
* fix: correct import path for follow-up module in InternalAgentSubStage
* feat: implement websockets transport mode selection for chat (#5410)
* feat: implement websockets transport mode selection for chat
- Added transport mode selection (SSE/WebSocket) in the chat component.
- Updated conversation sidebar to include transport mode options.
- Integrated transport mode handling in message sending logic.
- Refactored message sending functions to support both SSE and WebSocket.
- Enhanced WebSocket connection management and message handling.
- Updated localization files for transport mode labels.
- Configured Vite to support WebSocket proxying.
* feat(webchat): refactor message parsing logic and integrate new parsing function
* feat(chat): add websocket API key extraction and scope validation
* Revert "可选后端,实现前后端分离" (#5536)
---------
Signed-off-by: dependabot[bot]
Co-authored-by: can <51474963+weijintaocode@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: letr <123731298+letr007@users.noreply.github.com>
Co-authored-by: 搁浅
Co-authored-by: Helian Nuits
Co-authored-by: Gao Jinzhe <2968474907@qq.com>
Co-authored-by: DD斩首 <155905740+DDZS987@users.noreply.github.com>
Co-authored-by: Ubuntu
Co-authored-by: Claude Sonnet 4.5
Co-authored-by: エイカク <62183434+zouyonghe@users.noreply.github.com>
Co-authored-by: 鸦羽
Co-authored-by: Dt8333 <25431943+Dt8333@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Li-shi-ling <114913764+Li-shi-ling@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: Limitless <127183162+Limitless2023@users.noreply.github.com>
Co-authored-by: Limitless2023
Co-authored-by: evpeople <54983536+evpeople@users.noreply.github.com>
Co-authored-by: SnowNightt <127504703+SnowNightt@users.noreply.github.com>
Co-authored-by: xzj0898 <62733743+xzj0898@users.noreply.github.com>
Co-authored-by: stevessr <89645372+stevessr@users.noreply.github.com>
Co-authored-by: Waterwzy <2916963017@qq.com>
Co-authored-by: NayukiMeko
Co-authored-by: 時壹 <137363396+KBVsent@users.noreply.github.com>
Co-authored-by: sanyekana
Co-authored-by: Chiu Chun-Hsien <95356121+911218sky@users.noreply.github.com>
Co-authored-by: Dream Tokenizer <60459821+Trance-0@users.noreply.github.com>
Co-authored-by: NanoRocky <76585834+NanoRocky@users.noreply.github.com>
Co-authored-by: Pizero
Co-authored-by: 雪語 <167516635+YukiRa1n@users.noreply.github.com>
Co-authored-by: whatevertogo <1879483647@qq.com>
Co-authored-by: whatevertogo
Co-authored-by: 香草味的纳西妲喵 <151599587+VanillaNahida@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Lovely Moe Moli <44719954+moemoli@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Minidoracat
Co-authored-by: Chen <42998804+a61995987@users.noreply.github.com>
Co-authored-by: hanbings
Co-authored-by: tangsenfei <155090747+tangsenfei@users.noreply.github.com>
Co-authored-by: PyuraMazo <1605025385@qq.com>
Co-authored-by: Axi404 <118950647+Axi404@users.noreply.github.com>
Co-authored-by: 氕氙 <2014440212@qq.com>
Co-authored-by: Yunhao Cao <18230652+realquantumcookie@users.noreply.github.com>
Co-authored-by: exynos <110159911+exynos967@users.noreply.github.com>
Co-authored-by: Luna_Dol <86590429+Luna-channel@users.noreply.github.com>
Co-authored-by: CCCCCCTV <64309817+CCCCCCTV@users.noreply.github.com>
Co-authored-by: CAICAII <3360776475@qq.com>
Co-authored-by: 圣达生物多
From fed11fffa4a2826c3afb5e0be600324f148f2b82 Mon Sep 17 00:00:00 2001
From: LIghtJUNction
Date: Fri, 27 Feb 2026 22:20:38 +0800
Subject: [PATCH 03/12] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=88=86=E6=94=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.python-version | 2 +-
astrbot/cli/commands/cmd_init.py | 9 +-
astrbot/cli/commands/cmd_run.py | 20 +-
astrbot/core/config/default.py | 2 +-
.../aiocqhttp/aiocqhttp_platform_adapter.py | 2 +-
.../qqofficial_webhook/qo_webhook_server.py | 2 +-
.../platform/sources/wecom/wecom_adapter.py | 4 +-
astrbot/core/utils/io.py | 47 +-
astrbot/dashboard/routes/__init__.py | 8 +
astrbot/dashboard/routes/route.py | 6 +-
astrbot/dashboard/routes/static_file.py | 3 +
astrbot/dashboard/server.py | 442 ++++---
dashboard/env.d.ts | 6 +
dashboard/public/config.json | 13 +
dashboard/src/components/chat/LiveMode.vue | 1070 ++++++++--------
.../src/i18n/locales/en-US/features/auth.json | 13 +-
.../i18n/locales/en-US/features/settings.json | 8 +
.../src/i18n/locales/zh-CN/features/auth.json | 13 +-
.../i18n/locales/zh-CN/features/settings.json | 8 +
dashboard/src/main.ts | 265 ++--
dashboard/src/stores/api.ts | 70 ++
dashboard/src/views/Settings.vue | 1088 ++++++++++-------
.../views/authentication/auth/LoginPage.vue | 219 +++-
dashboard/tsconfig.json | 26 +-
dashboard/tsconfig.vite-config.json | 8 +-
dashboard/vite.config.ts | 48 +-
pyproject.toml | 3 +-
27 files changed, 2150 insertions(+), 1255 deletions(-)
create mode 100644 dashboard/public/config.json
create mode 100644 dashboard/src/stores/api.ts
diff --git a/.python-version b/.python-version
index fdcfcfdfc..e4fba2183 100644
--- a/.python-version
+++ b/.python-version
@@ -1 +1 @@
-3.12
\ No newline at end of file
+3.12
diff --git a/astrbot/cli/commands/cmd_init.py b/astrbot/cli/commands/cmd_init.py
index 6c0c34b99..0adbf3288 100644
--- a/astrbot/cli/commands/cmd_init.py
+++ b/astrbot/cli/commands/cmd_init.py
@@ -34,8 +34,13 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
for name, path in paths.items():
path.mkdir(parents=True, exist_ok=True)
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
-
- await check_dashboard(astrbot_root / "data")
+ if click.confirm(
+ "是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)",
+ default=True,
+ ):
+ await check_dashboard(astrbot_root / "data")
+ else:
+ click.echo("你可以使用在线面版(v4.14.4+),填写后端地址的方式来控制。")
@click.command()
diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py
index 23665dff3..3641d31c4 100644
--- a/astrbot/cli/commands/cmd_run.py
+++ b/astrbot/cli/commands/cmd_run.py
@@ -15,7 +15,8 @@ async def run_astrbot(astrbot_root: Path) -> None:
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
- await check_dashboard(astrbot_root / "data")
+ if os.environ.get("DASHBOARD_ENABLE") == "True":
+ await check_dashboard(astrbot_root / "data")
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
@@ -27,9 +28,17 @@ async def run_astrbot(astrbot_root: Path) -> None:
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
-@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
+@click.option(
+ "--host", "-H", help="Astrbot Dashboard Host,默认::", required=False, type=str
+)
+@click.option(
+ "--port", "-p", help="Astrbot Dashboard端口,默认6185", required=False, type=str
+)
+@click.option(
+ "--backend-only", is_flag=True, default=False, help="禁用WEBUI,仅启动后端"
+)
@click.command()
-def run(reload: bool, port: str) -> None:
+def run(reload: bool, host: str, port: str, backend_only: bool) -> None:
"""运行 AstrBot"""
try:
os.environ["ASTRBOT_CLI"] = "1"
@@ -43,8 +52,11 @@ def run(reload: bool, port: str) -> None:
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
sys.path.insert(0, str(astrbot_root))
- if port:
+ if port is not None:
os.environ["DASHBOARD_PORT"] = port
+ if host is not None:
+ os.environ["DASHBOARD_HOST"] = host
+ os.environ["DASHBOARD_ENABLE"] = str(not backend_only)
if reload:
click.echo("启用插件自动重载")
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index fa9d71d74..af2829733 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -195,7 +195,7 @@
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
"jwt_secret": "",
- "host": "0.0.0.0",
+ "host": "::",
"port": 6185,
"disable_access_log": True,
"ssl": {
diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py
index 45114382f..2f720dd1c 100644
--- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py
+++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py
@@ -419,7 +419,7 @@ async def _convert_handle_message_event(
def run(self) -> Awaitable[Any]:
if not self.host or not self.port:
logger.warning(
- "aiocqhttp: 未配置 ws_reverse_host 或 ws_reverse_port,将使用默认值:http://0.0.0.0:6199",
+ "aiocqhttp: 未配置 ws_reverse_host 或 ws_reverse_port,将使用默认值:http://[::]:6199",
)
self.host = "0.0.0.0"
self.port = 6199
diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py
index 5f35471ee..e1c5d457a 100644
--- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py
+++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py
@@ -21,7 +21,7 @@ def __init__(
self.secret = config["secret"]
self.port = config.get("port", 6196)
self.is_sandbox = config.get("is_sandbox", False)
- self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
+ self.callback_server_host = config.get("callback_server_host", "::")
if isinstance(self.port, str):
self.port = int(self.port)
diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py
index 6647db89f..c73e15a08 100644
--- a/astrbot/core/platform/sources/wecom/wecom_adapter.py
+++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py
@@ -43,7 +43,7 @@ class WecomServer:
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
self.server = quart.Quart(__name__)
self.port = int(cast(str, config.get("port")))
- self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
+ self.callback_server_host = config.get("callback_server_host", "::")
self.server.add_url_rule(
"/callback/command",
view_func=self.verify,
@@ -407,7 +407,7 @@ async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None:
abm.message = [Image(file=path, url=path)]
elif msgtype == "voice":
media_id = msg.get("voice", {}).get("media_id", "")
- resp: Response = await asyncio.get_event_loop().run_in_executor(
+ resp = await asyncio.get_event_loop().run_in_executor(
None,
self.client.media.download,
media_id,
diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py
index 0ce3624e8..911224dfe 100644
--- a/astrbot/core/utils/io.py
+++ b/astrbot/core/utils/io.py
@@ -1,3 +1,4 @@
+import asyncio
import base64
import logging
import os
@@ -7,6 +8,7 @@
import time
import uuid
import zipfile
+from ipaddress import IPv4Address, IPv6Address, ip_address
from pathlib import Path
import aiohttp
@@ -206,18 +208,53 @@ def file_to_base64(file_path: str) -> str:
return "base64://" + base64_str
-def get_local_ip_addresses():
+def get_local_ip_addresses() -> list[IPv4Address | IPv6Address]:
net_interfaces = psutil.net_if_addrs()
- network_ips = []
+ network_ips: list[IPv4Address | IPv6Address] = []
- for interface, addrs in net_interfaces.items():
+ for _, addrs in net_interfaces.items():
for addr in addrs:
- if addr.family == socket.AF_INET: # 使用 socket.AF_INET 代替 psutil.AF_INET
- network_ips.append(addr.address)
+ if addr.family == socket.AF_INET:
+ network_ips.append(ip_address(addr.address))
+ elif addr.family == socket.AF_INET6:
+ # 过滤掉 IPv6 的 link-local 地址(fe80:...)
+ ip = ip_address(addr.address.split("%")[0]) # 处理带 zone index 的情况
+ if not ip.is_link_local:
+ network_ips.append(ip)
return network_ips
+async def get_public_ip_address() -> list[IPv4Address | IPv6Address]:
+ urls = [
+ "https://api64.ipify.org",
+ "https://ident.me",
+ "https://ifconfig.me",
+ "https://icanhazip.com",
+ ]
+ found_ips: dict[int, IPv4Address | IPv6Address] = {}
+
+ async def fetch(session: aiohttp.ClientSession, url: str):
+ try:
+ async with session.get(url, timeout=3) as resp:
+ if resp.status == 200:
+ raw_ip = (await resp.text()).strip()
+ ip = ip_address(raw_ip)
+ if ip.version not in found_ips:
+ found_ips[ip.version] = ip
+ except Exception as e:
+ # Ignore errors from individual services so that a single failing
+ # endpoint does not prevent discovering the public IP from others.
+ logger.debug("Failed to fetch public IP from %s: %s", url, e)
+
+ async with aiohttp.ClientSession() as session:
+ tasks = [fetch(session, url) for url in urls]
+ await asyncio.gather(*tasks)
+
+ # 返回找到的所有 IP 对象列表
+ return list(found_ips.values())
+
+
async def get_dashboard_version():
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
if os.path.exists(dist_dir):
diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py
index fbbd0c7a0..652a9feef 100644
--- a/astrbot/dashboard/routes/__init__.py
+++ b/astrbot/dashboard/routes/__init__.py
@@ -9,16 +9,20 @@
from .cron import CronRoute
from .file import FileRoute
from .knowledge_base import KnowledgeBaseRoute
+from .live_chat import LiveChatRoute
from .log import LogRoute
from .open_api import OpenApiRoute
from .persona import PersonaRoute
from .platform import PlatformRoute
from .plugin import PluginRoute
+from .response import Response
+from .route import RouteContext
from .session_management import SessionManagementRoute
from .skills import SkillsRoute
from .stat import StatRoute
from .static_file import StaticFileRoute
from .subagent import SubAgentRoute
+from .t2i import T2iRoute
from .tools import ToolsRoute
from .update import UpdateRoute
@@ -46,4 +50,8 @@
"ToolsRoute",
"SkillsRoute",
"UpdateRoute",
+ "T2iRoute",
+ "LiveChatRoute",
+ "Response",
+ "RouteContext",
]
diff --git a/astrbot/dashboard/routes/route.py b/astrbot/dashboard/routes/route.py
index 53c623443..4fdc37971 100644
--- a/astrbot/dashboard/routes/route.py
+++ b/astrbot/dashboard/routes/route.py
@@ -1,4 +1,4 @@
-from dataclasses import dataclass
+from dataclasses import asdict, dataclass
from quart import Quart
@@ -57,3 +57,7 @@ def ok(self, data: dict | list | None = None, message: str | None = None):
self.data = data
self.message = message
return self
+
+ def to_json(self):
+ # Return a plain dict so callers can safely wrap with jsonify()
+ return asdict(self)
diff --git a/astrbot/dashboard/routes/static_file.py b/astrbot/dashboard/routes/static_file.py
index e056b6c5a..15fec95d1 100644
--- a/astrbot/dashboard/routes/static_file.py
+++ b/astrbot/dashboard/routes/static_file.py
@@ -5,6 +5,9 @@ class StaticFileRoute(Route):
def __init__(self, context: RouteContext) -> None:
super().__init__(context)
+ if "index" in self.app.view_functions:
+ return
+
index_ = [
"/",
"/auth/login",
diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py
index a9650cd06..e7fab8742 100644
--- a/astrbot/dashboard/server.py
+++ b/astrbot/dashboard/server.py
@@ -2,9 +2,12 @@
import hashlib
import logging
import os
+import platform
import socket
+from collections.abc import Callable
+from ipaddress import IPv4Address, IPv6Address, ip_address
from pathlib import Path
-from typing import Protocol, cast
+from typing import Protocol
import jwt
import psutil
@@ -13,6 +16,7 @@
from hypercorn.config import Config as HyperConfig
from quart import Quart, g, jsonify, request
from quart.logging import default_handler
+from quart_cors import cors
from astrbot.core import logger
from astrbot.core.config.default import VERSION
@@ -23,13 +27,6 @@
from .routes import *
from .routes.api_key import ALL_OPEN_API_SCOPES
-from .routes.backup import BackupRoute
-from .routes.live_chat import LiveChatRoute
-from .routes.platform import PlatformRoute
-from .routes.route import Response, RouteContext
-from .routes.session_management import SessionManagementRoute
-from .routes.subagent import SubAgentRoute
-from .routes.t2i import T2iRoute
class _AddrWithPort(Protocol):
@@ -46,6 +43,16 @@ def _parse_env_bool(value: str | None, default: bool) -> bool:
class AstrBotDashboard:
+ """AstrBot Web Dashboard"""
+
+ ALLOWED_ENDPOINT_PREFIXES = (
+ "/api/auth/login",
+ "/api/file",
+ "/api/platform/webhook",
+ "/api/stat/start-time",
+ "/api/backup/download",
+ )
+
def __init__(
self,
core_lifecycle: AstrBotCoreLifecycle,
@@ -56,67 +63,123 @@ def __init__(
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.db = db
+ self.shutdown_event = shutdown_event
+
+ self.enable_webui = self._check_webui_enabled()
+
+ self._init_paths(webui_dir)
+ self._init_app()
+ self.context = RouteContext(self.config, self.app)
+
+ self._init_routes(db)
+ self._init_plugin_route_index()
+ self._init_jwt_secret()
+
+ # ------------------------------------------------------------------
+ # 初始化阶段
+ # ------------------------------------------------------------------
+
+ def _check_webui_enabled(self) -> bool:
+ cfg = self.config.get("dashboard", {})
+ _env = os.environ.get("DASHBOARD_ENABLE")
+ if _env is not None:
+ return _env.lower() in ("true", "1", "yes")
+ return cfg.get("enable", True)
- # 参数指定webui目录
+ def _init_paths(self, webui_dir: str | None):
if webui_dir and os.path.exists(webui_dir):
self.data_path = os.path.abspath(webui_dir)
else:
self.data_path = os.path.abspath(
- os.path.join(get_astrbot_data_path(), "dist"),
+ os.path.join(get_astrbot_data_path(), "dist")
)
- self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
- APP = self.app # noqa
- self.app.config["MAX_CONTENT_LENGTH"] = (
- 128 * 1024 * 1024
- ) # 将 Flask 允许的最大上传文件体大小设置为 128 MB
- cast(DefaultJSONProvider, self.app.json).sort_keys = False
+ def _init_app(self):
+ """初始化 Quart 应用"""
+ global APP
+ self.app = Quart(
+ "AstrBotDashboard",
+ static_folder=self.data_path,
+ static_url_path="/",
+ )
+ APP = self.app
+ self.app.json_provider_class = DefaultJSONProvider
+ self.app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16MB
+
+ # 配置 CORS
+ self.app = cors(
+ self.app,
+ allow_origin="*",
+ allow_headers=["Authorization", "Content-Type", "X-API-Key"],
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ )
+
+ @self.app.route("/")
+ async def index():
+ if not self.enable_webui:
+ return "WebUI is disabled."
+ return await self.app.send_static_file("index.html")
+
+ @self.app.errorhandler(404)
+ async def not_found(e):
+ if not self.enable_webui:
+ return "WebUI is disabled."
+ if request.path.startswith("/api/"):
+ return jsonify(Response().error("Not Found").to_json()), 404
+ return await self.app.send_static_file("index.html")
+
+ @self.app.before_serving
+ async def startup():
+ pass
+
+ @self.app.after_serving
+ async def shutdown():
+ pass
+
self.app.before_request(self.auth_middleware)
- # token 用于验证请求
logging.getLogger(self.app.name).removeHandler(default_handler)
- self.context = RouteContext(self.config, self.app)
- self.ur = UpdateRoute(
- self.context,
- core_lifecycle.astrbot_updator,
- core_lifecycle,
+
+ def _init_routes(self, db: BaseDatabase):
+ UpdateRoute(
+ self.context, self.core_lifecycle.astrbot_updator, self.core_lifecycle
)
- self.sr = StatRoute(self.context, db, core_lifecycle)
- self.pr = PluginRoute(
- self.context,
- core_lifecycle,
- core_lifecycle.plugin_manager,
+ StatRoute(self.context, db, self.core_lifecycle)
+ PluginRoute(
+ self.context, self.core_lifecycle, self.core_lifecycle.plugin_manager
)
self.command_route = CommandRoute(self.context)
- self.cr = ConfigRoute(self.context, core_lifecycle)
- self.lr = LogRoute(self.context, core_lifecycle.log_broker)
+ self.cr = ConfigRoute(self.context, self.core_lifecycle)
+ self.lr = LogRoute(self.context, self.core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context)
self.api_key_route = ApiKeyRoute(self.context, db)
- self.chat_route = ChatRoute(self.context, db, core_lifecycle)
+ self.chat_route = ChatRoute(self.context, db, self.core_lifecycle)
self.open_api_route = OpenApiRoute(
self.context,
db,
- core_lifecycle,
+ self.core_lifecycle,
self.chat_route,
)
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
- self.tools_root = ToolsRoute(self.context, core_lifecycle)
- self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
- self.skills_route = SkillsRoute(self.context, core_lifecycle)
- self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
+ self.tools_root = ToolsRoute(self.context, self.core_lifecycle)
+ self.subagent_route = SubAgentRoute(self.context, self.core_lifecycle)
+ self.skills_route = SkillsRoute(self.context, self.core_lifecycle)
+ self.conversation_route = ConversationRoute(
+ self.context, db, self.core_lifecycle
+ )
self.file_route = FileRoute(self.context)
self.session_management_route = SessionManagementRoute(
self.context,
db,
- core_lifecycle,
+ self.core_lifecycle,
)
- self.persona_route = PersonaRoute(self.context, db, core_lifecycle)
- self.cron_route = CronRoute(self.context, core_lifecycle)
- self.t2i_route = T2iRoute(self.context, core_lifecycle)
- self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
- self.platform_route = PlatformRoute(self.context, core_lifecycle)
- self.backup_route = BackupRoute(self.context, db, core_lifecycle)
- self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
+ self.persona_route = PersonaRoute(self.context, db, self.core_lifecycle)
+ self.cron_route = CronRoute(self.context, self.core_lifecycle)
+ self.t2i_route = T2iRoute(self.context, self.core_lifecycle)
+ self.kb_route = KnowledgeBaseRoute(self.context, self.core_lifecycle)
+ self.platform_route = PlatformRoute(self.context, self.core_lifecycle)
+ self.backup_route = BackupRoute(self.context, db, self.core_lifecycle)
+ self.live_chat_route = LiveChatRoute(self.context, db, self.core_lifecycle)
self.app.add_url_rule(
"/api/plug/",
@@ -124,20 +187,35 @@ def __init__(
methods=["GET", "POST"],
)
- self.shutdown_event = shutdown_event
-
- self._init_jwt_secret()
+ def _init_plugin_route_index(self):
+ """将插件路由索引,避免 O(n) 查找"""
+ self._plugin_route_map: dict[tuple[str, str], Callable] = {}
+
+ for (
+ route,
+ handler,
+ methods,
+ _,
+ ) in self.core_lifecycle.star_context.registered_web_apis:
+ for method in methods:
+ self._plugin_route_map[(route, method)] = handler
+
+ def _init_jwt_secret(self):
+ dashboard_cfg = self.config.setdefault("dashboard", {})
+ if not dashboard_cfg.get("jwt_secret"):
+ dashboard_cfg["jwt_secret"] = os.urandom(32).hex()
+ self.config.save_config()
+ logger.info("Initialized random JWT secret for dashboard.")
+ self._jwt_secret = dashboard_cfg["jwt_secret"]
- async def srv_plug_route(self, subpath, *args, **kwargs):
- """插件路由"""
- registered_web_apis = self.core_lifecycle.star_context.registered_web_apis
- for api in registered_web_apis:
- route, view_handler, methods, _ = api
- if route == f"/{subpath}" and request.method in methods:
- return await view_handler(*args, **kwargs)
- return jsonify(Response().error("未找到该路由").__dict__)
+ # ------------------------------------------------------------------
+ # Middleware中间件
+ # ------------------------------------------------------------------
async def auth_middleware(self):
+ # 放行CORS预检请求
+ if request.method == "OPTIONS":
+ return None
if not request.path.startswith("/api"):
return None
if request.path.startswith("/api/v1"):
@@ -174,33 +252,46 @@ async def auth_middleware(self):
await self.db.touch_api_key(api_key.key_id)
return None
- allowed_endpoints = [
- "/api/auth/login",
- "/api/file",
- "/api/platform/webhook",
- "/api/stat/start-time",
- "/api/backup/download", # 备份下载使用 URL 参数传递 token
- ]
- if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
+ if any(request.path.startswith(p) for p in self.ALLOWED_ENDPOINT_PREFIXES):
return None
- # 声明 JWT
+
token = request.headers.get("Authorization")
if not token:
- r = jsonify(Response().error("未授权").__dict__)
- r.status_code = 401
- return r
- token = token.removeprefix("Bearer ")
+ return self._unauthorized("未授权")
+
try:
- payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
+ payload = jwt.decode(
+ token.removeprefix("Bearer "),
+ self._jwt_secret,
+ algorithms=["HS256"],
+ options={"require": ["username"]},
+ )
g.username = payload["username"]
except jwt.ExpiredSignatureError:
- r = jsonify(Response().error("Token 过期").__dict__)
- r.status_code = 401
- return r
- except jwt.InvalidTokenError:
- r = jsonify(Response().error("Token 无效").__dict__)
- r.status_code = 401
- return r
+ return self._unauthorized("Token 过期")
+ except jwt.PyJWTError:
+ return self._unauthorized("Token 无效")
+
+ @staticmethod
+ def _unauthorized(msg: str):
+ r = jsonify(Response().error(msg).to_json())
+ r.status_code = 401
+ return r
+
+ # ------------------------------------------------------------------
+ # 插件路由
+ # ------------------------------------------------------------------
+
+ async def srv_plug_route(self, subpath: str, *args, **kwargs):
+ handler = self._plugin_route_map.get((f"/{subpath}", request.method))
+ if not handler:
+ return jsonify(Response().error("未找到该路由").to_json())
+
+ try:
+ return await handler(*args, **kwargs)
+ except Exception:
+ logger.exception("插件 Web API 执行异常")
+ return jsonify(Response().error("插件 Web API 执行异常").to_json())
@staticmethod
def _extract_raw_api_key() -> str | None:
@@ -230,126 +321,87 @@ def _get_required_open_api_scope(path: str) -> str | None:
}
return scope_map.get(path)
- def check_port_in_use(self, port: int) -> bool:
+ def check_port_in_use(self, host: str, port: int) -> bool:
"""跨平台检测端口是否被占用"""
- try:
- # 创建 IPv4 TCP Socket
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- # 设置超时时间
- sock.settimeout(2)
- result = sock.connect_ex(("127.0.0.1", port))
- sock.close()
- # result 为 0 表示端口被占用
- return result == 0
- except Exception as e:
- logger.warning(f"检查端口 {port} 时发生错误: {e!s}")
- # 如果出现异常,保守起见认为端口可能被占用
- return True
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ try:
+ s.bind((host, port))
+ return False
+ except OSError:
+ return True
def get_process_using_port(self, port: int) -> str:
- """获取占用端口的进程详细信息"""
+ """获取占用端口的进程信息"""
try:
- for conn in psutil.net_connections(kind="inet"):
- if cast(_AddrWithPort, conn.laddr).port == port:
- try:
- process = psutil.Process(conn.pid)
- # 获取详细信息
- proc_info = [
- f"进程名: {process.name()}",
- f"PID: {process.pid}",
- f"执行路径: {process.exe()}",
- f"工作目录: {process.cwd()}",
- f"启动命令: {' '.join(process.cmdline())}",
- ]
- return "\n ".join(proc_info)
- except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
- return f"无法获取进程详细信息(可能需要管理员权限): {e!s}"
- return "未找到占用进程"
+ for proc in psutil.process_iter(["pid", "name", "connections"]):
+ for conn in proc.info["connections"] or []: # type: ignore
+ if conn.laddr.port == port:
+ return f"PID: {proc.info['pid']}, Name: {proc.info['name']}" # type: ignore
except Exception as e:
return f"获取进程信息失败: {e!s}"
+ return "未知进程"
- def _init_jwt_secret(self) -> None:
- if not self.config.get("dashboard", {}).get("jwt_secret", None):
- # 如果没有设置 JWT 密钥,则生成一个新的密钥
- jwt_secret = os.urandom(32).hex()
- self.config["dashboard"]["jwt_secret"] = jwt_secret
- self.config.save_config()
- logger.info("Initialized random JWT secret for dashboard.")
- self._jwt_secret = self.config["dashboard"]["jwt_secret"]
-
- def run(self):
- ip_addr = []
- dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {})
- port = (
- os.environ.get("DASHBOARD_PORT")
- or os.environ.get("ASTRBOT_DASHBOARD_PORT")
- or dashboard_config.get("port", 6185)
+ # ------------------------------------------------------------------
+ # 启动与运行
+ # ------------------------------------------------------------------
+
+ def run(self) -> None:
+ """Run dashboard server (blocking)"""
+ if not self.enable_webui:
+ logger.warning(
+ "WebUI 已禁用 (dashboard.enable=false or DASHBOARD_ENABLE=false)"
+ )
+
+ dashboard_config = self.config.get("dashboard", {})
+ host = os.environ.get("DASHBOARD_HOST") or dashboard_config.get(
+ "host", "0.0.0.0"
)
- host = (
- os.environ.get("DASHBOARD_HOST")
- or os.environ.get("ASTRBOT_DASHBOARD_HOST")
- or dashboard_config.get("host", "0.0.0.0")
+ port = int(
+ os.environ.get("DASHBOARD_PORT") or dashboard_config.get("port", 6185)
)
- enable = dashboard_config.get("enable", True)
ssl_config = dashboard_config.get("ssl", {})
- if not isinstance(ssl_config, dict):
- ssl_config = {}
ssl_enable = _parse_env_bool(
- os.environ.get("DASHBOARD_SSL_ENABLE")
- or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"),
- bool(ssl_config.get("enable", False)),
+ os.environ.get("DASHBOARD_SSL_ENABLE"),
+ ssl_config.get("enable", False),
)
- scheme = "https" if ssl_enable else "http"
- if not enable:
- logger.info("WebUI 已被禁用")
- return None
+ scheme = "https" if ssl_enable else "http"
+ display_host = f"[{host}]" if ":" in host else host
- logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}")
- if host == "0.0.0.0":
+ if self.enable_webui:
logger.info(
- "提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)",
+ "正在启动 WebUI + API, 监听地址: %s://%s:%s",
+ scheme,
+ display_host,
+ port,
)
-
- if host not in ["localhost", "127.0.0.1"]:
- try:
- ip_addr = get_local_ip_addresses()
- except Exception as _:
- pass
- if isinstance(port, str):
- port = int(port)
-
- if self.check_port_in_use(port):
- process_info = self.get_process_using_port(port)
- logger.error(
- f"错误:端口 {port} 已被占用\n"
- f"占用信息: \n {process_info}\n"
- f"请确保:\n"
- f"1. 没有其他 AstrBot 实例正在运行\n"
- f"2. 端口 {port} 没有被其他程序占用\n"
- f"3. 如需使用其他端口,请修改配置文件",
+ else:
+ logger.info(
+ "正在启动 API Server (WebUI 已分离), 监听地址: %s://%s:%s",
+ scheme,
+ display_host,
+ port,
)
- raise Exception(f"端口 {port} 已被占用")
-
- parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"]
- parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
- for ip in ip_addr:
- parts.append(f" ➜ 网络: {scheme}://{ip}:{port}\n")
- parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
- display = "".join(parts)
-
- if not ip_addr:
- display += (
- "可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
- )
+ check_hosts = {host}
+ if host not in ("127.0.0.1", "localhost", "::1"):
+ check_hosts.add("127.0.0.1")
+ for check_host in check_hosts:
+ if self.check_port_in_use(check_host, port):
+ info = self.get_process_using_port(port)
+ raise RuntimeError(f"端口 {port} 已被占用\n{info}")
- logger.info(display)
+ if self.enable_webui:
+ self._print_access_urls(host, port, scheme)
# 配置 Hypercorn
config = HyperConfig()
- config.bind = [f"{host}:{port}"]
+ binds: list[str] = [self._build_bind(host, port)]
+ # 参考:https://github.com/pgjones/hypercorn/issues/85
+ if host == "::" and platform.system() in ("Windows", "Darwin"):
+ binds.append(self._build_bind("0.0.0.0", port))
+ config.bind = binds
+
if ssl_enable:
cert_file = (
os.environ.get("DASHBOARD_SSL_CERT")
@@ -392,12 +444,48 @@ def run(self):
if disable_access_log:
config.accesslog = None
else:
- # 启用访问日志,使用简洁格式
config.accesslog = "-"
config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s"
- return serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
+ return asyncio.run(
+ serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
+ )
+
+ @staticmethod
+ def _build_bind(host: str, port: int) -> str:
+ try:
+ ip: IPv4Address | IPv6Address = ip_address(host)
+ return f"[{ip}]:{port}" if ip.version == 6 else f"{ip}:{port}"
+ except ValueError:
+ return f"{host}:{port}"
+
+ def _print_access_urls(self, host: str, port: int, scheme: str = "http") -> None:
+ local_ips: list[IPv4Address | IPv6Address] = get_local_ip_addresses()
+
+ parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动\n\n"]
+
+ parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
+
+ if host in ("::", "0.0.0.0"):
+ for ip in local_ips:
+ if ip.is_loopback:
+ continue
+
+ if ip.version == 6:
+ display_url = f"{scheme}://[{ip}]:{port}"
+ else:
+ display_url = f"{scheme}://{ip}:{port}"
+
+ parts.append(f" ➜ 网络: {display_url}\n")
+
+ parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
+
+ if not local_ips:
+ parts.append(
+ "可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
+ )
+
+ logger.info("".join(parts))
- async def shutdown_trigger(self) -> None:
+ async def shutdown_trigger(self):
await self.shutdown_event.wait()
- logger.info("AstrBot WebUI 已经被优雅地关闭")
diff --git a/dashboard/env.d.ts b/dashboard/env.d.ts
index b4b350830..a90bd47be 100644
--- a/dashboard/env.d.ts
+++ b/dashboard/env.d.ts
@@ -7,3 +7,9 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+
+declare module "*.vue" {
+ import type { DefineComponent } from "vue";
+ const component: DefineComponent<{}, {}, any>;
+ export default component;
+}
diff --git a/dashboard/public/config.json b/dashboard/public/config.json
new file mode 100644
index 000000000..0d7e84a8a
--- /dev/null
+++ b/dashboard/public/config.json
@@ -0,0 +1,13 @@
+{
+ "apiBaseUrl": "",
+ "presets": [
+ {
+ "name": "Default (Auto)",
+ "url": ""
+ },
+ {
+ "name": "Localhost",
+ "url": "http://localhost:6185"
+ }
+ ]
+}
diff --git a/dashboard/src/components/chat/LiveMode.vue b/dashboard/src/components/chat/LiveMode.vue
index 2740459d9..2e11277ad 100644
--- a/dashboard/src/components/chat/LiveMode.vue
+++ b/dashboard/src/components/chat/LiveMode.vue
@@ -1,65 +1,110 @@
-
-
+
+
-
We're developing Astr Live Mode on ChatUI & Desktop right now. Stay tuned!
-
-
-
-
- {{ statusText }}
-
-
-
-
- WAV Assemble: {{ (metrics.wav_assemble_time * 1000).toFixed(0)
- }}ms
- LLM First Token Latency: {{ (metrics.llm_ttft * 1000).toFixed(0)
- }}ms
- LLM Total Latency: {{ (metrics.llm_total_time * 1000).toFixed(0)
- }}ms
- TTS First Frame Latency: {{ (metrics.tts_first_frame_time *
- 1000).toFixed(0) }}ms
- TTS Total Larency: {{ (metrics.tts_total_time * 1000).toFixed(0)
- }}ms
- Speak -> First TTS Frame: {{ (metrics.speak_to_first_frame *
- 1000).toFixed(0) }}ms
- Speak -> End: {{ (metrics.wav_to_tts_total_time *
- 1000).toFixed(0) }}ms
- STT Provider: {{ metrics.stt }}
- TTS Provider: {{ metrics.tts }}
- Chat Model: {{ metrics.chat_model }}
-
+
We're developing Astr Live Mode on ChatUI & Desktop right now. Stay
+ tuned!
+
+
+
+
+ {{ statusText }}
+
+
+
+
+ WAV Assemble:
+ {{ (metrics.wav_assemble_time * 1000).toFixed(0) }}ms
+ LLM First Token Latency:
+ {{ (metrics.llm_ttft * 1000).toFixed(0) }}ms
+ LLM Total Latency:
+ {{ (metrics.llm_total_time * 1000).toFixed(0) }}ms
+ TTS First Frame Latency:
+ {{ (metrics.tts_first_frame_time * 1000).toFixed(0) }}ms
+ TTS Total Larency:
+ {{ (metrics.tts_total_time * 1000).toFixed(0) }}ms
+ Speak -> First TTS Frame:
+ {{ (metrics.speak_to_first_frame * 1000).toFixed(0) }}ms
+ Speak -> End:
+ {{ (metrics.wav_to_tts_total_time * 1000).toFixed(0) }}ms
+ STT Provider: {{ metrics.stt }}
+ TTS Provider: {{ metrics.tts }}
+ Chat Model: {{ metrics.chat_model }}
+
+
diff --git a/dashboard/src/i18n/locales/en-US/features/auth.json b/dashboard/src/i18n/locales/en-US/features/auth.json
index 5c44558a0..c59deb2a0 100644
--- a/dashboard/src/i18n/locales/en-US/features/auth.json
+++ b/dashboard/src/i18n/locales/en-US/features/auth.json
@@ -10,5 +10,16 @@
"theme": {
"switchToDark": "Switch to Dark Theme",
"switchToLight": "Switch to Light Theme"
+ },
+ "serverConfig": {
+ "title": "Server Configuration",
+ "description": "If the backend is not on the same origin (host/port), please specify the full URL here.",
+ "label": "API Base URL",
+ "placeholder": "e.g. http://localhost:6185",
+ "hint": "Empty for default (relative path)",
+ "presetLabel": "Quick Select Preset",
+ "save": "Save & Reload",
+ "cancel": "Cancel",
+ "tooltip": "Server Configuration"
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json
index 19232125f..0c616c3d0 100644
--- a/dashboard/src/i18n/locales/en-US/features/settings.json
+++ b/dashboard/src/i18n/locales/en-US/features/settings.json
@@ -1,6 +1,14 @@
{
"network": {
"title": "Network",
+ "server": {
+ "title": "Server Address",
+ "subtitle": "Configure backend API URL",
+ "label": "API Base URL",
+ "placeholder": "e.g. http://localhost:6185",
+ "hint": "Empty for default (relative path)",
+ "save": "Save & Reload"
+ },
"githubProxy": {
"title": "GitHub Proxy Address",
"subtitle": "Set the GitHub proxy address used when downloading plugins or updating AstrBot. This is effective in mainland China's network environment. Can be customized, input takes effect in real time. All addresses do not guarantee stability. If errors occur when updating plugins/projects, please first check if the proxy address is working properly.",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/auth.json b/dashboard/src/i18n/locales/zh-CN/features/auth.json
index d6da99943..4318eca95 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/auth.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/auth.json
@@ -10,5 +10,16 @@
"theme": {
"switchToDark": "切换到深色主题",
"switchToLight": "切换到浅色主题"
+ },
+ "serverConfig": {
+ "title": "服务器配置",
+ "description": "如果后端服务不在同源(主机/端口不同),请在此指定完整 URL。",
+ "label": "API 基础地址",
+ "placeholder": "例如:http://localhost:6185",
+ "hint": "留空以使用默认设置(相对路径)",
+ "presetLabel": "快速选择预设",
+ "save": "保存并刷新",
+ "cancel": "取消",
+ "tooltip": "服务器配置"
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json
index 19c1c7c41..f9a703f7e 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/settings.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json
@@ -1,6 +1,14 @@
{
"network": {
"title": "网络",
+ "server": {
+ "title": "服务器地址",
+ "subtitle": "配置后端 API 地址",
+ "label": "API 基础地址",
+ "placeholder": "例如:http://localhost:6185",
+ "hint": "留空以使用默认设置(相对路径)",
+ "save": "保存并刷新"
+ },
"githubProxy": {
"title": "GitHub 加速地址",
"subtitle": "设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效。所有地址均不保证稳定性,如果在更新插件/项目时出现报错,请首先检查加速地址是否能正常使用。",
diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts
index 687166654..08a5aeadd 100644
--- a/dashboard/src/main.ts
+++ b/dashboard/src/main.ts
@@ -1,116 +1,181 @@
-import { createApp } from 'vue';
-import { createPinia } from 'pinia';
-import App from './App.vue';
-import { router } from './router';
-import vuetify from './plugins/vuetify';
-import confirmPlugin from './plugins/confirmPlugin';
-import { setupI18n } from './i18n/composables';
-import '@/scss/style.scss';
-import VueApexCharts from 'vue3-apexcharts';
-
-import print from 'vue3-print-nb';
-import { loader } from '@guolao/vue-monaco-editor'
-import axios from 'axios';
-
-// 初始化新的i18n系统,等待完成后再挂载应用
-setupI18n().then(() => {
- console.log('🌍 新i18n系统初始化完成');
-
- const app = createApp(App);
- app.use(router);
- const pinia = createPinia();
- app.use(pinia);
- app.use(print);
- app.use(VueApexCharts);
- app.use(vuetify);
- app.use(confirmPlugin);
- app.mount('#app');
-
- // 挂载后同步 Vuetify 主题
- import('./stores/customizer').then(({ useCustomizerStore }) => {
- const customizer = useCustomizerStore(pinia);
- vuetify.theme.global.name.value = customizer.uiTheme;
- const storedPrimary = localStorage.getItem('themePrimary');
- const storedSecondary = localStorage.getItem('themeSecondary');
- if (storedPrimary || storedSecondary) {
- const themes = vuetify.theme.themes.value;
- ['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
- const theme = themes[name];
- if (!theme?.colors) return;
- if (storedPrimary) theme.colors.primary = storedPrimary;
- if (storedSecondary) theme.colors.secondary = storedSecondary;
- if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary;
- if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary;
- });
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import App from "./App.vue";
+import { router } from "./router";
+import vuetify from "./plugins/vuetify";
+import confirmPlugin from "./plugins/confirmPlugin";
+import { setupI18n } from "./i18n/composables";
+import "@/scss/style.scss";
+import VueApexCharts from "vue3-apexcharts";
+
+import print from "vue3-print-nb";
+import { loader } from "@guolao/vue-monaco-editor";
+import axios from "axios";
+
+// 1. 定义加载配置的函数
+async function loadAppConfig() {
+ try {
+ // 加上时间戳防止浏览器缓存 config.json
+ const response = await fetch(`/config.json?t=${new Date().getTime()}`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
}
- });
-}).catch(error => {
- console.error('❌ 新i18n系统初始化失败:', error);
-
- // 即使i18n初始化失败,也要挂载应用(使用回退机制)
- const app = createApp(App);
- app.use(router);
- const pinia = createPinia();
- app.use(pinia);
- app.use(print);
- app.use(VueApexCharts);
- app.use(vuetify);
- app.use(confirmPlugin);
- app.mount('#app');
-
+ return await response.json();
+ } catch (error) {
+ console.warn("Failed to load config.json, falling back to default.", error);
+ return {};
+ }
+}
+
+function mountApp(app: any, pinia: any) {
+ app.mount("#app");
+
// 挂载后同步 Vuetify 主题
- import('./stores/customizer').then(({ useCustomizerStore }) => {
+ import("./stores/customizer").then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
- const storedPrimary = localStorage.getItem('themePrimary');
- const storedSecondary = localStorage.getItem('themeSecondary');
+ const storedPrimary = localStorage.getItem("themePrimary");
+ const storedSecondary = localStorage.getItem("themeSecondary");
if (storedPrimary || storedSecondary) {
const themes = vuetify.theme.themes.value;
- ['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
+ ["PurpleTheme", "PurpleThemeDark"].forEach((name) => {
const theme = themes[name];
if (!theme?.colors) return;
if (storedPrimary) theme.colors.primary = storedPrimary;
if (storedSecondary) theme.colors.secondary = storedSecondary;
- if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary;
- if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary;
+ if (storedPrimary && theme.colors.darkprimary)
+ theme.colors.darkprimary = storedPrimary;
+ if (storedSecondary && theme.colors.darksecondary)
+ theme.colors.darksecondary = storedSecondary;
});
}
});
-});
+}
+async function initApp() {
+ // 等待配置加载
+ const config = await loadAppConfig();
+ const configApiUrl = config.apiBaseUrl || "";
+ const presets = config.presets || [];
-axios.interceptors.request.use((config) => {
- const token = localStorage.getItem('token');
- if (token) {
- config.headers['Authorization'] = `Bearer ${token}`;
- }
- const locale = localStorage.getItem('astrbot-locale');
- if (locale) {
- config.headers['Accept-Language'] = locale;
- }
- return config;
-});
-
-// Keep fetch() calls consistent with axios by automatically attaching the JWT.
-// Some parts of the UI use fetch directly; without this, those requests will 401.
-const _origFetch = window.fetch.bind(window);
-window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
- const token = localStorage.getItem('token');
- if (!token) return _origFetch(input, init);
-
- const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined));
- if (!headers.has('Authorization')) {
- headers.set('Authorization', `Bearer ${token}`);
- }
- const locale = localStorage.getItem('astrbot-locale');
- if (locale && !headers.has('Accept-Language')) {
- headers.set('Accept-Language', locale);
+ // 优先使用 localStorage 中的配置,其次是 config.json,最后是空字符串
+ const localApiUrl = localStorage.getItem("apiBaseUrl");
+ const apiBaseUrl = localApiUrl !== null ? localApiUrl : configApiUrl;
+
+ if (apiBaseUrl) {
+ console.log(
+ `API Base URL set to: ${apiBaseUrl} (Local: ${localApiUrl}, Config: ${configApiUrl})`,
+ );
}
- return _origFetch(input, { ...init, headers });
-};
-
-loader.config({
- paths: {
- vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs',
- },
-})
+
+ // 配置 Axios 全局 Base URL
+ axios.defaults.baseURL = apiBaseUrl;
+
+ axios.interceptors.request.use((config) => {
+ const token = localStorage.getItem("token");
+ if (token) {
+ config.headers["Authorization"] = `Bearer ${token}`;
+ }
+ const locale = localStorage.getItem("astrbot-locale");
+ if (locale) {
+ config.headers["Accept-Language"] = locale;
+ }
+ return config;
+ });
+
+ // Keep fetch() calls consistent with axios by automatically attaching the JWT.
+ // Some parts of the UI use fetch directly; without this, those requests will 401.
+ // Also handle apiBaseUrl for fetch
+ const _origFetch = window.fetch.bind(window);
+ window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
+ let url = input;
+
+ // 动态获取当前的 Base URL (可能已被 Store 修改)
+ const currentBaseUrl = axios.defaults.baseURL;
+
+ // 如果是字符串路径且以 /api 开头,并且配置了 Base URL,则拼接
+ if (
+ typeof input === "string" &&
+ input.startsWith("/api") &&
+ currentBaseUrl
+ ) {
+ // 移除 apiBaseUrl 尾部的斜杠
+ const cleanBase = currentBaseUrl.replace(/\/+$/, "");
+ // 移除 input 开头的斜杠
+ const cleanPath = input.replace(/^\/+/, "");
+ url = `${cleanBase}/${cleanPath}`;
+ }
+
+ const token = localStorage.getItem("token");
+
+ const headers = new Headers(
+ init?.headers ||
+ (typeof input !== "string" && "headers" in input
+ ? (input as Request).headers
+ : undefined),
+ );
+ if (token && !headers.has("Authorization")) {
+ headers.set("Authorization", `Bearer ${token}`);
+ }
+
+ const locale = localStorage.getItem("astrbot-locale");
+ if (locale && !headers.has("Accept-Language")) {
+ headers.set("Accept-Language", locale);
+ }
+
+ return _origFetch(url, { ...init, headers });
+ };
+
+ loader.config({
+ paths: {
+ vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs",
+ },
+ });
+
+ // 初始化新的i18n系统,等待完成后再挂载应用
+ setupI18n()
+ .then(async () => {
+ console.log("🌍 新i18n系统初始化完成");
+
+ const app = createApp(App);
+ app.use(router);
+ const pinia = createPinia();
+ app.use(pinia);
+
+ // Initialize API Store with presets
+ const { useApiStore } = await import("@/stores/api");
+ const apiStore = useApiStore(pinia);
+ apiStore.setPresets(presets);
+
+ app.use(print);
+ app.use(VueApexCharts);
+ app.use(vuetify);
+ app.use(confirmPlugin);
+
+ mountApp(app, pinia);
+ })
+ .catch(async (error) => {
+ console.error("❌ 新i18n系统初始化失败:", error);
+
+ // 即使i18n初始化失败,也要挂载应用(使用回退机制)
+ const app = createApp(App);
+ app.use(router);
+ const pinia = createPinia();
+ app.use(pinia);
+
+ // Initialize API Store with presets
+ const { useApiStore } = await import("@/stores/api");
+ const apiStore = useApiStore(pinia);
+ apiStore.setPresets(presets);
+
+ app.use(print);
+ app.use(VueApexCharts);
+ app.use(vuetify);
+ app.use(confirmPlugin);
+
+ mountApp(app, pinia);
+ });
+}
+
+// 启动应用
+initApp();
diff --git a/dashboard/src/stores/api.ts b/dashboard/src/stores/api.ts
new file mode 100644
index 000000000..b664c1d95
--- /dev/null
+++ b/dashboard/src/stores/api.ts
@@ -0,0 +1,70 @@
+import { defineStore } from "pinia";
+import axios from "axios";
+
+export type ApiPreset = {
+ name: string;
+ url: string;
+};
+
+export const useApiStore = defineStore({
+ id: "api",
+ state: () => ({
+ // 优先从 localStorage 读取用户手动设置的地址
+ apiBaseUrl: localStorage.getItem("apiBaseUrl") || "",
+ configPresets: [] as ApiPreset[],
+ customPresets: JSON.parse(
+ localStorage.getItem("customPresets") || "[]",
+ ) as ApiPreset[],
+ }),
+ getters: {
+ presets: (state): ApiPreset[] => [
+ ...state.configPresets,
+ ...state.customPresets,
+ ],
+ },
+ actions: {
+ setPresets(presets: ApiPreset[]) {
+ this.configPresets = presets;
+ },
+
+ addPreset(preset: ApiPreset) {
+ this.customPresets.push(preset);
+ localStorage.setItem("customPresets", JSON.stringify(this.customPresets));
+ },
+
+ removePreset(name: string) {
+ this.customPresets = this.customPresets.filter((p) => p.name !== name);
+ localStorage.setItem("customPresets", JSON.stringify(this.customPresets));
+ },
+
+ /**
+ * 设置 API 基础地址
+ * @param url 后端地址,例如 http://localhost:6185
+ */
+ setApiBaseUrl(url: string) {
+ // 移除尾部斜杠,确保一致性
+ const cleanUrl = url ? url.replace(/\/+$/, "") : "";
+
+ this.apiBaseUrl = cleanUrl;
+
+ if (cleanUrl) {
+ localStorage.setItem("apiBaseUrl", cleanUrl);
+ } else {
+ localStorage.removeItem("apiBaseUrl");
+ }
+
+ // 立即更新 axios 配置
+ axios.defaults.baseURL = cleanUrl;
+ },
+
+ /**
+ * 初始化 API 配置
+ * 通常在应用启动时调用,同步 localStorage 到 axios
+ */
+ init() {
+ if (this.apiBaseUrl) {
+ axios.defaults.baseURL = this.apiBaseUrl;
+ }
+ },
+ },
+});
diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue
index 8ec447dac..027effc34 100644
--- a/dashboard/src/views/Settings.vue
+++ b/dashboard/src/views/Settings.vue
@@ -1,488 +1,750 @@
-
-
-
-
- {{ tm('network.title') }}
-
-
-
-
-
- {{ tm('sidebar.title') }}
-
-
-
-
-
- {{ tm('theme.title') }}
-
-
-
-
-
-
-
-
-
-
-
- mdi-restore
- {{ tm('theme.customize.reset') }}
-
-
-
-
-
- {{ tm('system.title') }}
-
-
-
- mdi-backup-restore
- {{ tm('system.backup.button') }}
-
-
-
-
- {{ tm('system.restart.button') }}
-
-
- {{ tm('apiKey.title') }}
-
-
-
-
- {{ tm('apiKey.manageTitle') }}
-
-
-
- mdi-help-circle-outline
-
-
- {{ tm('apiKey.docsLink') }}
-
-
+
+
+
+
+
+ {{ tm("network.title") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm("network.server.presets") }}
+
+
+ Add Preset
+
+
+
+
+
+
+
+ Add Preset
+
+
+
+
+
+ {{ preset.name }}
+
+
+
+
+
+
+ {{ tm("network.server.save") }}
+
-
-
-
-
-
-
-
-
-
- {{ tm('apiKey.permanentWarning') }}
-
-
-
-
- mdi-key-plus
- {{ tm('apiKey.create') }}
-
-
-
-
- {{ tm('apiKey.scopes') }}
-
-
- {{ scope.label }}
-
-
-
-
-
-
-
- {{ tm('apiKey.plaintextHint') }}
-
- mdi-content-copy {{ tm('apiKey.copy') }}
-
-
- {{ createdApiKeyPlaintext }}
-
-
-
-
-
-
-
- {{ tm('apiKey.table.name') }}
- {{ tm('apiKey.table.prefix') }}
- {{ tm('apiKey.table.scopes') }}
- {{ tm('apiKey.table.status') }}
- {{ tm('apiKey.table.lastUsed') }}
- {{ tm('apiKey.table.createdAt') }}
- {{ tm('apiKey.table.actions') }}
-
-
-
-
- {{ item.name }}
- {{ item.key_prefix }}
- {{ (item.scopes || []).join(', ') }}
-
-
- {{ item.is_revoked || item.is_expired ? tm('apiKey.status.inactive') : tm('apiKey.status.active') }}
-
-
- {{ formatDate(item.last_used_at) }}
- {{ formatDate(item.created_at) }}
-
-
- {{ tm('apiKey.revoke') }}
-
-
- {{ tm('apiKey.delete') }}
-
-
-
-
-
- {{ tm('apiKey.empty') }}
-
-
-
-
-
-
-
-
-
-
- {{ tm('system.migration.button') }}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {{ tm("apiKey.title") }}
+
+
+
+
+ {{ tm("apiKey.manageTitle") }}
+
+
+
+ mdi-help-circle-outline
+
+
+ {{ tm("apiKey.docsLink") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm("apiKey.permanentWarning") }}
+
+
+
+
+ mdi-key-plus
+ {{ tm("apiKey.create") }}
+
+
+
+
+
+ {{ tm("apiKey.scopes") }}
+
+
+
+ {{ scope.label }}
+
+
+
+
+
+
+
+ {{ tm("apiKey.plaintextHint") }}
+
+ mdi-content-copy {{ tm("apiKey.copy") }}
+
+
+ {{
+ createdApiKeyPlaintext
+ }}
+
+
+
+
+
+
+
+ {{ tm("apiKey.table.name") }}
+ {{ tm("apiKey.table.prefix") }}
+ {{ tm("apiKey.table.scopes") }}
+ {{ tm("apiKey.table.status") }}
+ {{ tm("apiKey.table.lastUsed") }}
+ {{ tm("apiKey.table.createdAt") }}
+ {{ tm("apiKey.table.actions") }}
+
+
+
+
+ {{ item.name }}
+
+ {{ item.key_prefix }}
+
+ {{ (item.scopes || []).join(", ") }}
+
+
+ {{
+ item.is_revoked || item.is_expired
+ ? tm("apiKey.status.inactive")
+ : tm("apiKey.status.active")
+ }}
+
+
+ {{ formatDate(item.last_used_at) }}
+ {{ formatDate(item.created_at) }}
+
+
+ {{ tm("apiKey.revoke") }}
+
+
+ {{ tm("apiKey.delete") }}
+
+
+
+
+
+ {{ tm("apiKey.empty") }}
+
+
+
+
+
+
+
+
+
+ {{ tm("system.migration.button") }}
+
+
+ {{ tm("sidebar.title") }}
+
+
+
+
+
+ {{ tm("style.title") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-palette
+ {{ tm("common.save") }}
+
+
+
+
+
+ {{ tm("backup.title") }}
+
+
+
+
+ {{ tm("backup.open") }}
+
+
+
+
+
+
+
+ {{ tm("reset.button") }}
+
+
+
+
+
+
+
+ {{ tm("system.restart.button") }}
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/views/authentication/auth/LoginPage.vue b/dashboard/src/views/authentication/auth/LoginPage.vue
index c647dc8e5..b659eae27 100644
--- a/dashboard/src/views/authentication/auth/LoginPage.vue
+++ b/dashboard/src/views/authentication/auth/LoginPage.vue
@@ -1,23 +1,56 @@
From b8ffecf5008c46bcd174f48373b4790eb0bfe746 Mon Sep 17 00:00:00 2001
From: LIghtJUNction
Date: Fri, 27 Feb 2026 23:41:43 +0800
Subject: [PATCH 08/12] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=89=8D=E7=AB=AF/?=
=?UTF-8?q?=E5=85=81=E8=AE=B8=E9=80=80=E5=87=BA=E7=99=BB=E5=BD=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
dashboard/package.json | 2 +-
.../src/components/chat/StandaloneChat.vue | 488 +++++-----
.../src/i18n/locales/en-US/core/header.json | 3 +-
.../i18n/locales/en-US/features/settings.json | 5 +
.../src/i18n/locales/zh-CN/core/header.json | 3 +-
.../i18n/locales/zh-CN/features/settings.json | 5 +
.../full/vertical-header/VerticalHeader.vue | 880 ++++++++++++------
dashboard/src/views/Settings.vue | 26 +-
8 files changed, 892 insertions(+), 520 deletions(-)
diff --git a/dashboard/package.json b/dashboard/package.json
index 7b4a7f071..63fdcb181 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -64,7 +64,7 @@
"sass": "1.66.1",
"sass-loader": "13.3.2",
"typescript": "5.1.6",
- "vite": "4.4.9",
+ "vite": "^7.3.1",
"vue-cli-plugin-vuetify": "2.5.8",
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
diff --git a/dashboard/src/components/chat/StandaloneChat.vue b/dashboard/src/components/chat/StandaloneChat.vue
index 69fac13f9..ba0cd4eeb 100644
--- a/dashboard/src/components/chat/StandaloneChat.vue
+++ b/dashboard/src/components/chat/StandaloneChat.vue
@@ -1,165 +1,177 @@
-
-
-
-
-
-
-
-
- Hello, I'm
- AstrBot ⭐
-
-
- 测试配置: {{ configId || 'default' }}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Hello, I'm
+ AstrBot ⭐
-
+
+ 测试配置: {{ configId || "default" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("core.common.imagePreview") }}
+
+
+
+
+
-
-
-
-
-
- {{ t('core.common.imagePreview') }}
-
-
-
-
-
-
-
+
diff --git a/dashboard/src/i18n/locales/en-US/core/header.json b/dashboard/src/i18n/locales/en-US/core/header.json
index 4da98b8dd..a4593d2a2 100644
--- a/dashboard/src/i18n/locales/en-US/core/header.json
+++ b/dashboard/src/i18n/locales/en-US/core/header.json
@@ -10,7 +10,8 @@
"theme": {
"light": "Light Mode",
"dark": "Dark Mode"
- }
+ },
+ "logout": "Log Out"
},
"updateDialog": {
"title": "Update AstrBot",
diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json
index 6216a2675..27a6c2b76 100644
--- a/dashboard/src/i18n/locales/en-US/features/settings.json
+++ b/dashboard/src/i18n/locales/en-US/features/settings.json
@@ -65,6 +65,11 @@
"subtitle": "Restart AstrBot",
"button": "Restart"
},
+ "logout": {
+ "title": "Log Out",
+ "subtitle": "Log out of the current account",
+ "button": "Log Out"
+ },
"migration": {
"title": "Data Migration to v4.0.0",
"subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant",
diff --git a/dashboard/src/i18n/locales/zh-CN/core/header.json b/dashboard/src/i18n/locales/zh-CN/core/header.json
index 3bb985033..8823e0f7d 100644
--- a/dashboard/src/i18n/locales/zh-CN/core/header.json
+++ b/dashboard/src/i18n/locales/zh-CN/core/header.json
@@ -10,7 +10,8 @@
"theme": {
"light": "浅色模式",
"dark": "深色模式"
- }
+ },
+ "logout": "退出登录"
},
"updateDialog": {
"title": "更新 AstrBot",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json
index c9745569f..e80eae4e9 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/settings.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json
@@ -65,6 +65,11 @@
"subtitle": "重启 AstrBot",
"button": "重启"
},
+ "logout": {
+ "title": "退出登录",
+ "subtitle": "退出当前账号,回到登录界面",
+ "button": "退出登录"
+ },
"migration": {
"title": "数据迁移到 v4.0.0 格式",
"subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手",
diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue
index 47905d4ff..9ec2d8945 100644
--- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue
+++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue
@@ -1,72 +1,73 @@