feat: add first notice feature with multilingual support and UI integration

This commit is contained in:
Soulter
2026-02-12 00:00:53 +08:00
parent dc995af34b
commit cd7755fe07
7 changed files with 201 additions and 38 deletions
+14
View File
@@ -0,0 +1,14 @@
## Welcome to AstrBot
🌟 Thank you for using AstrBot!
AstrBot is an Agentic AI assistant for personal and group chats, with support for multiple IM platforms and a wide range of built-in features. We hope it brings you an efficient and enjoyable experience. ❤️
Important notice:
AstrBot is a **free and open-source software project** protected by the AGPLv3 license. You can find the full source code and related resources on our [**official website**](https://astrbot.app) and [**GitHub**](https://github.com/astrbotdevs/astrbot).
As of now, AstrBot has **no commercial services of any kind**, and the official team **will never charge users any fees** under any name.
If anyone asks you to pay while using AstrBot, **you are likely being scammed**. Please request a refund immediately and report it to us by email.
📮 Official email: [community@astrbot.app](mailto:community@astrbot.app)
+14
View File
@@ -0,0 +1,14 @@
## 欢迎使用 AstrBot
🌟 感谢您使用 AstrBot
AstrBot 是一款可接入多种 IM 平台的 Agentic AI 个人 / 群聊助手,内置多项强大功能,希望能为您带来高效、愉快的使用体验。❤️
我们想特别说明:
AstrBot 是受 AGPLv3 开源协议保护的**免费开源软件项目**,您可以在[**官方网站**](https://astrbot.app)、[**GitHub**](https://github.com/astrbotdevs/astrbot) 上找到 AstrBot 的全部源代码及相关资源。
截至目前,AstrBot 项目**未开展任何形式的商业化服务**,官方**不会以任何名义向用户收取费用**。
如果您在使用 AstrBot 的过程中被要求付费,**表明您已经遭遇诈骗行为**。请立即向相关方申请退款,并及时通过邮件向我们反馈。
📮 官方邮箱:[community@astrbot.app](mailto:community@astrbot.app)
+38
View File
@@ -4,6 +4,7 @@ import threading
import time import time
import traceback import traceback
from functools import cmp_to_key from functools import cmp_to_key
from pathlib import Path
import aiohttp import aiohttp
import psutil import psutil
@@ -37,6 +38,7 @@ class StatRoute(Route):
"/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection), "/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
"/stat/changelog": ("GET", self.get_changelog), "/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions), "/stat/changelog/list": ("GET", self.list_changelog_versions),
"/stat/first-notice": ("GET", self.get_first_notice),
} }
self.db_helper = db_helper self.db_helper = db_helper
self.register_routes() self.register_routes()
@@ -279,3 +281,39 @@ class StatRoute(Route):
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__ return Response().error(f"Error: {e!s}").__dict__
async def get_first_notice(self):
"""读取项目根目录 FIRST_NOTICE.md 内容。"""
try:
locale = (request.args.get("locale") or "").strip()
if not re.match(r"^[A-Za-z0-9_-]*$", locale):
locale = ""
base_path = Path(get_astrbot_path())
candidates: list[Path] = []
if locale:
candidates.append(base_path / f"FIRST_NOTICE.{locale}.md")
if locale.lower().startswith("zh"):
candidates.append(base_path / "FIRST_NOTICE.zh-CN.md")
elif locale.lower().startswith("en"):
candidates.append(base_path / "FIRST_NOTICE.en-US.md")
candidates.extend(
[
base_path / "FIRST_NOTICE.en-US.md",
base_path / "FIRST_NOTICE.md",
],
)
for notice_path in candidates:
if not notice_path.is_file():
continue
content = notice_path.read_text(encoding="utf-8")
if content.strip():
return Response().ok({"content": content}).__dict__
return Response().ok({"content": None}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
@@ -35,7 +35,7 @@ const props = defineProps({
mode: { mode: {
type: String, type: String,
default: "readme", default: "readme",
validator: (value) => ["readme", "changelog"].includes(value), validator: (value) => ["readme", "changelog", "first-notice"].includes(value),
}, },
}); });
@@ -166,19 +166,50 @@ const renderedHtml = computed(() => {
}); });
const modeConfig = computed(() => { const modeConfig = computed(() => {
const isChangelog = props.mode === "changelog"; if (props.mode === "changelog") {
const keyBase = `core.common.${isChangelog ? "changelog" : "readme"}`; return {
title: t("core.common.changelog.title"),
loading: t("core.common.changelog.loading"),
emptyTitle: t("core.common.changelog.empty.title"),
emptySubtitle: t("core.common.changelog.empty.subtitle"),
apiPath: "/api/plugin/changelog",
showGithubButton: false,
showRefreshButton: true,
refreshLabel: t("core.common.readme.buttons.refresh"),
};
}
if (props.mode === "first-notice") {
return {
title: t("core.common.firstNotice.title"),
loading: t("core.common.firstNotice.loading"),
emptyTitle: t("core.common.firstNotice.empty.title"),
emptySubtitle: t("core.common.firstNotice.empty.subtitle"),
apiPath: "/api/stat/first-notice",
showGithubButton: false,
showRefreshButton: false,
refreshLabel: "",
};
}
return { return {
title: t(`${keyBase}.title`), title: t("core.common.readme.title"),
loading: t(`${keyBase}.loading`), loading: t("core.common.readme.loading"),
emptyTitle: t(`${keyBase}.empty.title`), emptyTitle: t("core.common.readme.empty.title"),
emptySubtitle: t(`${keyBase}.empty.subtitle`), emptySubtitle: t("core.common.readme.empty.subtitle"),
apiPath: `/api/plugin/${isChangelog ? "changelog" : "readme"}`, apiPath: "/api/plugin/readme",
showGithubButton: true,
showRefreshButton: true,
refreshLabel: t("core.common.readme.buttons.refresh"),
}; };
}); });
const requiresPluginName = computed(
() => props.mode === "readme" || props.mode === "changelog",
);
async function fetchContent() { async function fetchContent() {
if (!props.pluginName) return; if (requiresPluginName.value && !props.pluginName) return;
const requestId = ++lastRequestId.value; const requestId = ++lastRequestId.value;
loading.value = true; loading.value = true;
content.value = null; content.value = null;
@@ -186,9 +217,13 @@ async function fetchContent() {
isEmpty.value = false; isEmpty.value = false;
try { try {
const res = await axios.get( let params;
`${modeConfig.value.apiPath}?name=${props.pluginName}`, if (requiresPluginName.value) {
); params = { name: props.pluginName };
} else if (props.mode === "first-notice") {
params = { locale: locale.value };
}
const res = await axios.get(modeConfig.value.apiPath, { params });
if (requestId !== lastRequestId.value) return; if (requestId !== lastRequestId.value) return;
if (res.data.status === "ok") { if (res.data.status === "ok") {
@@ -207,7 +242,9 @@ async function fetchContent() {
watch( watch(
[() => props.show, () => props.pluginName, () => props.mode], [() => props.show, () => props.pluginName, () => props.mode],
([show, name]) => { ([show, name]) => {
if (show && name) fetchContent(); if (!show) return;
if (requiresPluginName.value && !name) return;
fetchContent();
}, },
{ immediate: true }, { immediate: true },
); );
@@ -273,22 +310,26 @@ function openExternalLink(url) {
if (!url) return; if (!url) return;
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
} }
const showActionArea = computed(() => {
const hasGithub = modeConfig.value.showGithubButton && !!props.repoUrl;
return hasGithub || modeConfig.value.showRefreshButton;
});
</script> </script>
<template> <template>
<v-dialog v-model="_show" width="800"> <v-dialog v-model="_show" width="800">
<v-card> <v-card>
<v-card-title class="d-flex justify-space-between align-center"> <v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ modeConfig.title }}</span> <span class="text-h2 pa-2">{{ modeConfig.title }}</span>
<v-btn icon @click="_show = false" variant="text"> <v-btn icon @click="_show = false" variant="text">
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
</v-btn> </v-btn>
</v-card-title> </v-card-title>
<v-divider></v-divider> <v-card-text style="overflow-y: auto">
<v-card-text style="height: 70vh; overflow-y: auto"> <div v-if="showActionArea" class="d-flex justify-space-between mb-4">
<div class="d-flex justify-space-between mb-4">
<v-btn <v-btn
v-if="repoUrl" v-if="modeConfig.showGithubButton && repoUrl"
color="primary" color="primary"
prepend-icon="mdi-github" prepend-icon="mdi-github"
@click="openExternalLink(repoUrl)" @click="openExternalLink(repoUrl)"
@@ -296,11 +337,12 @@ function openExternalLink(url) {
{{ t("core.common.readme.buttons.viewOnGithub") }} {{ t("core.common.readme.buttons.viewOnGithub") }}
</v-btn> </v-btn>
<v-btn <v-btn
v-if="modeConfig.showRefreshButton"
color="secondary" color="secondary"
prepend-icon="mdi-refresh" prepend-icon="mdi-refresh"
@click="fetchContent" @click="fetchContent"
> >
{{ t("core.common.readme.buttons.refresh") }} {{ modeConfig.refreshLabel }}
</v-btn> </v-btn>
</div> </div>
@@ -357,7 +399,6 @@ function openExternalLink(url) {
</p> </p>
</div> </div>
</v-card-text> </v-card-text>
<v-divider></v-divider>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" @click="_show = false"> <v-btn color="primary" variant="tonal" @click="_show = false">
@@ -104,5 +104,13 @@
"edit": "Edit", "edit": "Edit",
"copy": "Copy", "copy": "Copy",
"noData": "No data available" "noData": "No data available"
},
"firstNotice": {
"title": "First Notice",
"loading": "Loading first notice...",
"empty": {
"title": "No first notice content available",
"subtitle": "FIRST_NOTICE.md was not found or is empty."
}
} }
} }
@@ -104,5 +104,13 @@
"edit": "编辑", "edit": "编辑",
"copy": "复制", "copy": "复制",
"noData": "暂无数据" "noData": "暂无数据"
},
"firstNotice": {
"title": "首次提示",
"loading": "正在加载首次提示...",
"empty": {
"title": "暂无首次提示内容",
"subtitle": "未找到 FIRST_NOTICE.md 或文件为空。"
}
} }
} }
+58 -18
View File
@@ -5,55 +5,92 @@ import axios from 'axios';
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue'; import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue'; import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue'; import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import Chat from '@/components/chat/Chat.vue'; import Chat from '@/components/chat/Chat.vue';
import { useCustomizerStore } from '@/stores/customizer'; import { useCustomizerStore } from '@/stores/customizer';
import { useRouterLoadingStore } from '@/stores/routerLoading'; import { useRouterLoadingStore } from '@/stores/routerLoading';
import { useI18n } from '@/i18n/composables';
const FIRST_NOTICE_SEEN_KEY = 'astrbot:first_notice_seen:v1';
const customizer = useCustomizerStore(); const customizer = useCustomizerStore();
const { locale } = useI18n();
const route = useRoute(); const route = useRoute();
const routerLoadingStore = useRouterLoadingStore(); const routerLoadingStore = useRouterLoadingStore();
// 计算是否在聊天页面(非全屏模式)
const isChatPage = computed(() => { const isChatPage = computed(() => {
return route.path.startsWith('/chat'); return route.path.startsWith('/chat');
}); });
// 计算是否显示 sidebar(仅在 bot 模式下显示)
const showSidebar = computed(() => { const showSidebar = computed(() => {
return customizer.viewMode === 'bot'; return customizer.viewMode === 'bot';
}); });
// 计算是否显示 chat 页面(在 chat 模式下显示)
const showChatPage = computed(() => { const showChatPage = computed(() => {
return customizer.viewMode === 'chat'; return customizer.viewMode === 'chat';
}); });
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null); const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
const showFirstNoticeDialog = ref(false);
// 检查是否需要迁移 const checkMigration = async (): Promise<boolean> => {
const checkMigration = async () => {
try { try {
const response = await axios.get('/api/stat/version'); const response = await axios.get('/api/stat/version');
if (response.data.status === 'ok' && response.data.data.need_migration) { if (response.data.status === 'ok' && response.data.data.need_migration) {
// 需要迁移,显示迁移对话框
if (migrationDialog.value && typeof migrationDialog.value.open === 'function') { if (migrationDialog.value && typeof migrationDialog.value.open === 'function') {
const result = await migrationDialog.value.open(); const result = await migrationDialog.value.open();
if (result.success) { if (result.success) {
// 迁移成功,可以显示成功消息
console.log('Migration completed successfully:', result.message); console.log('Migration completed successfully:', result.message);
// 可以考虑刷新页面或显示成功通知
window.location.reload(); window.location.reload();
} }
} }
return true;
} }
} catch (error) { } catch (error) {
console.error('Failed to check migration status:', error); console.error('Failed to check migration status:', error);
} }
return false;
};
const maybeShowFirstNotice = async () => {
if (localStorage.getItem(FIRST_NOTICE_SEEN_KEY) === '1') {
return;
}
try {
const response = await axios.get('/api/stat/first-notice', {
params: { locale: locale.value },
});
if (response.data.status !== 'ok') {
return;
}
const content = response.data?.data?.content;
if (typeof content === 'string' && content.trim().length > 0) {
showFirstNoticeDialog.value = true;
return;
}
localStorage.setItem(FIRST_NOTICE_SEEN_KEY, '1');
} catch (error) {
console.error('Failed to load first notice:', error);
}
};
const onFirstNoticeDialogUpdate = (visible: boolean) => {
showFirstNoticeDialog.value = visible;
if (!visible) {
localStorage.setItem(FIRST_NOTICE_SEEN_KEY, '1');
}
}; };
onMounted(() => { onMounted(() => {
// 页面加载时检查是否需要迁移 setTimeout(async () => {
setTimeout(checkMigration, 1000); // 延迟1秒执行,确保页面完全加载 const migrationPending = await checkMigration();
if (!migrationPending) {
await maybeShowFirstNotice();
}
}, 1000);
}); });
</script> </script>
@@ -62,7 +99,6 @@ onMounted(() => {
<v-app :theme="useCustomizerStore().uiTheme" <v-app :theme="useCustomizerStore().uiTheme"
:class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']" :class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']"
> >
<!-- 路由切换进度条 -->
<v-progress-linear <v-progress-linear
v-if="routerLoadingStore.isLoading" v-if="routerLoadingStore.isLoading"
:model-value="routerLoadingStore.progress" :model-value="routerLoadingStore.progress"
@@ -74,15 +110,15 @@ onMounted(() => {
/> />
<VerticalHeaderVue /> <VerticalHeaderVue />
<VerticalSidebarVue v-if="showSidebar" /> <VerticalSidebarVue v-if="showSidebar" />
<v-main :style="{ <v-main :style="{
height: showChatPage ? 'calc(100vh - 55px)' : undefined, height: showChatPage ? 'calc(100vh - 55px)' : undefined,
overflow: showChatPage ? 'hidden' : undefined overflow: showChatPage ? 'hidden' : undefined
}"> }">
<v-container <v-container
fluid fluid
class="page-wrapper" class="page-wrapper"
:class="{ 'chat-mode-container': showChatPage }" :class="{ 'chat-mode-container': showChatPage }"
:style="{ :style="{
height: showChatPage ? '100%' : 'calc(100% - 8px)', height: showChatPage ? '100%' : 'calc(100% - 8px)',
padding: (isChatPage || showChatPage) ? '0' : undefined, padding: (isChatPage || showChatPage) ? '0' : undefined,
minHeight: showChatPage ? 'unset' : undefined minHeight: showChatPage ? 'unset' : undefined
@@ -95,9 +131,13 @@ onMounted(() => {
</div> </div>
</v-container> </v-container>
</v-main> </v-main>
<!-- Migration Dialog -->
<MigrationDialog ref="migrationDialog" /> <MigrationDialog ref="migrationDialog" />
<ReadmeDialog
:show="showFirstNoticeDialog"
mode="first-notice"
@update:show="onFirstNoticeDialogUpdate"
/>
</v-app> </v-app>
</v-locale-provider> </v-locale-provider>
</template> </template>