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 traceback
from functools import cmp_to_key
from pathlib import Path
import aiohttp
import psutil
@@ -37,6 +38,7 @@ class StatRoute(Route):
"/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
"/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions),
"/stat/first-notice": ("GET", self.get_first_notice),
}
self.db_helper = db_helper
self.register_routes()
@@ -279,3 +281,39 @@ class StatRoute(Route):
except Exception as e:
logger.error(traceback.format_exc())
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: {
type: String,
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 isChangelog = props.mode === "changelog";
const keyBase = `core.common.${isChangelog ? "changelog" : "readme"}`;
if (props.mode === "changelog") {
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 {
title: t(`${keyBase}.title`),
loading: t(`${keyBase}.loading`),
emptyTitle: t(`${keyBase}.empty.title`),
emptySubtitle: t(`${keyBase}.empty.subtitle`),
apiPath: `/api/plugin/${isChangelog ? "changelog" : "readme"}`,
title: t("core.common.readme.title"),
loading: t("core.common.readme.loading"),
emptyTitle: t("core.common.readme.empty.title"),
emptySubtitle: t("core.common.readme.empty.subtitle"),
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() {
if (!props.pluginName) return;
if (requiresPluginName.value && !props.pluginName) return;
const requestId = ++lastRequestId.value;
loading.value = true;
content.value = null;
@@ -186,9 +217,13 @@ async function fetchContent() {
isEmpty.value = false;
try {
const res = await axios.get(
`${modeConfig.value.apiPath}?name=${props.pluginName}`,
);
let params;
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 (res.data.status === "ok") {
@@ -207,7 +242,9 @@ async function fetchContent() {
watch(
[() => props.show, () => props.pluginName, () => props.mode],
([show, name]) => {
if (show && name) fetchContent();
if (!show) return;
if (requiresPluginName.value && !name) return;
fetchContent();
},
{ immediate: true },
);
@@ -273,22 +310,26 @@ function openExternalLink(url) {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
const showActionArea = computed(() => {
const hasGithub = modeConfig.value.showGithubButton && !!props.repoUrl;
return hasGithub || modeConfig.value.showRefreshButton;
});
</script>
<template>
<v-dialog v-model="_show" width="800">
<v-card>
<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-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text style="height: 70vh; overflow-y: auto">
<div class="d-flex justify-space-between mb-4">
<v-card-text style="overflow-y: auto">
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
<v-btn
v-if="repoUrl"
v-if="modeConfig.showGithubButton && repoUrl"
color="primary"
prepend-icon="mdi-github"
@click="openExternalLink(repoUrl)"
@@ -296,11 +337,12 @@ function openExternalLink(url) {
{{ t("core.common.readme.buttons.viewOnGithub") }}
</v-btn>
<v-btn
v-if="modeConfig.showRefreshButton"
color="secondary"
prepend-icon="mdi-refresh"
@click="fetchContent"
>
{{ t("core.common.readme.buttons.refresh") }}
{{ modeConfig.refreshLabel }}
</v-btn>
</div>
@@ -357,7 +399,6 @@ function openExternalLink(url) {
</p>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" @click="_show = false">
@@ -104,5 +104,13 @@
"edit": "Edit",
"copy": "Copy",
"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": "编辑",
"copy": "复制",
"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 VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import Chat from '@/components/chat/Chat.vue';
import { useCustomizerStore } from '@/stores/customizer';
import { useRouterLoadingStore } from '@/stores/routerLoading';
import { useI18n } from '@/i18n/composables';
const FIRST_NOTICE_SEEN_KEY = 'astrbot:first_notice_seen:v1';
const customizer = useCustomizerStore();
const { locale } = useI18n();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
// 计算是否在聊天页面(非全屏模式)
const isChatPage = computed(() => {
return route.path.startsWith('/chat');
});
// 计算是否显示 sidebar(仅在 bot 模式下显示)
const showSidebar = computed(() => {
return customizer.viewMode === 'bot';
});
// 计算是否显示 chat 页面(在 chat 模式下显示)
const showChatPage = computed(() => {
return customizer.viewMode === 'chat';
});
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
const showFirstNoticeDialog = ref(false);
// 检查是否需要迁移
const checkMigration = async () => {
const checkMigration = async (): Promise<boolean> => {
try {
const response = await axios.get('/api/stat/version');
if (response.data.status === 'ok' && response.data.data.need_migration) {
// 需要迁移,显示迁移对话框
if (migrationDialog.value && typeof migrationDialog.value.open === 'function') {
const result = await migrationDialog.value.open();
if (result.success) {
// 迁移成功,可以显示成功消息
console.log('Migration completed successfully:', result.message);
// 可以考虑刷新页面或显示成功通知
window.location.reload();
}
}
return true;
}
} catch (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(() => {
// 页面加载时检查是否需要迁移
setTimeout(checkMigration, 1000); // 延迟1秒执行,确保页面完全加载
setTimeout(async () => {
const migrationPending = await checkMigration();
if (!migrationPending) {
await maybeShowFirstNotice();
}
}, 1000);
});
</script>
@@ -62,7 +99,6 @@ onMounted(() => {
<v-app :theme="useCustomizerStore().uiTheme"
:class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']"
>
<!-- 路由切换进度条 -->
<v-progress-linear
v-if="routerLoadingStore.isLoading"
:model-value="routerLoadingStore.progress"
@@ -74,15 +110,15 @@ onMounted(() => {
/>
<VerticalHeaderVue />
<VerticalSidebarVue v-if="showSidebar" />
<v-main :style="{
<v-main :style="{
height: showChatPage ? 'calc(100vh - 55px)' : undefined,
overflow: showChatPage ? 'hidden' : undefined
}">
<v-container
fluid
class="page-wrapper"
<v-container
fluid
class="page-wrapper"
:class="{ 'chat-mode-container': showChatPage }"
:style="{
:style="{
height: showChatPage ? '100%' : 'calc(100% - 8px)',
padding: (isChatPage || showChatPage) ? '0' : undefined,
minHeight: showChatPage ? 'unset' : undefined
@@ -95,9 +131,13 @@ onMounted(() => {
</div>
</v-container>
</v-main>
<!-- Migration Dialog -->
<MigrationDialog ref="migrationDialog" />
<ReadmeDialog
:show="showFirstNoticeDialog"
mode="first-notice"
@update:show="onFirstNoticeDialogUpdate"
/>
</v-app>
</v-locale-provider>
</template>