feat: add welcome feature with localized content and onboarding steps
This commit is contained in:
@@ -33,7 +33,6 @@ tests/astrbot_plugin_openai
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
package-lock.json
|
||||
package.json
|
||||
yarn.lock
|
||||
|
||||
# Operating System
|
||||
|
||||
@@ -59,6 +59,7 @@ export class I18nLoader {
|
||||
{ name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' },
|
||||
{ name: 'features/persona', path: 'features/persona.json' },
|
||||
{ name: 'features/migration', path: 'features/migration.json' },
|
||||
{ name: 'features/welcome', path: 'features/welcome.json' },
|
||||
|
||||
// 消息模块
|
||||
{ name: 'messages/errors', path: 'messages/errors.json' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"welcome": "Welcome",
|
||||
"dashboard": "Dashboard",
|
||||
"platforms": "Platforms",
|
||||
"providers": "Providers",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"greeting": {
|
||||
"morning": "Good morning, welcome to AstrBot",
|
||||
"afternoon": "Good afternoon, welcome to AstrBot",
|
||||
"evening": "Good evening, welcome to AstrBot",
|
||||
"newYear": "Happy New Year!"
|
||||
},
|
||||
"subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.",
|
||||
"onboard": {
|
||||
"title": "Quick Onboarding",
|
||||
"subtitle": "Complete initialization directly on the welcome page.",
|
||||
"step1Title": "Configure Platform Bot",
|
||||
"step1Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
|
||||
"step2Title": "Configure AI Model",
|
||||
"step2Desc": "Configure AI models for AstrBot.",
|
||||
"configure": "Configure",
|
||||
"skip": "Skip",
|
||||
"pending": "Pending",
|
||||
"completed": "Completed",
|
||||
"skipped": "Skipped",
|
||||
"platformLoadFailed": "Failed to load platform configuration",
|
||||
"providerLoadFailed": "Failed to load provider configuration",
|
||||
"providerUpdateFailed": "Failed to update default chat provider in config file \"default\"",
|
||||
"providerDefaultUpdated": "Default chat provider in config file \"default\" has been set to {id}"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Resources",
|
||||
"githubDesc": "Give us a Star!",
|
||||
"docsTitle": "Documentation",
|
||||
"docsDesc": "Read the official AstrBot documentation."
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"welcome": "欢迎",
|
||||
"dashboard": "数据统计",
|
||||
"platforms": "机器人",
|
||||
"providers": "模型提供商",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"greeting": {
|
||||
"morning": "上午好,欢迎使用 AstrBot",
|
||||
"afternoon": "下午好,欢迎使用 AstrBot",
|
||||
"evening": "晚上好,欢迎使用 AstrBot",
|
||||
"newYear": "新年快乐!"
|
||||
},
|
||||
"subtitle": "可以先完成基础引导,平台和对话提供商都支持稍后再配置。",
|
||||
"onboard": {
|
||||
"title": "快速引导",
|
||||
"subtitle": "欢迎页可直接完成初始化。",
|
||||
"step1Title": "配置平台机器人",
|
||||
"step1Desc": "将 AstrBot 连接到 QQ、飞书、企业微信、Telegram 等 IM 平台。",
|
||||
"step2Title": "配置 AI 模型",
|
||||
"step2Desc": "为 AstrBot 配置 AI 模型。",
|
||||
"configure": "去配置",
|
||||
"skip": "跳过",
|
||||
"pending": "待处理",
|
||||
"completed": "已完成",
|
||||
"skipped": "已跳过",
|
||||
"platformLoadFailed": "加载平台配置失败",
|
||||
"providerLoadFailed": "加载提供商配置失败",
|
||||
"providerUpdateFailed": "更新 default 配置文件默认对话提供商失败",
|
||||
"providerDefaultUpdated": "已将 default 配置文件的默认对话提供商设置为 {id}"
|
||||
},
|
||||
"resources": {
|
||||
"title": "相关资源",
|
||||
"githubDesc": "给 AstrBot 点个 Star 吧!",
|
||||
"docsTitle": "文档",
|
||||
"docsDesc": "查阅 AstrBot 的官方文档。"
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import zhCNPersona from './locales/zh-CN/features/persona.json';
|
||||
import zhCNMigration from './locales/zh-CN/features/migration.json';
|
||||
import zhCNCommand from './locales/zh-CN/features/command.json';
|
||||
import zhCNSubagent from './locales/zh-CN/features/subagent.json';
|
||||
import zhCNWelcome from './locales/zh-CN/features/welcome.json';
|
||||
|
||||
import zhCNErrors from './locales/zh-CN/messages/errors.json';
|
||||
import zhCNSuccess from './locales/zh-CN/messages/success.json';
|
||||
@@ -76,6 +77,7 @@ import enUSPersona from './locales/en-US/features/persona.json';
|
||||
import enUSMigration from './locales/en-US/features/migration.json';
|
||||
import enUSCommand from './locales/en-US/features/command.json';
|
||||
import enUSSubagent from './locales/en-US/features/subagent.json';
|
||||
import enUSWelcome from './locales/en-US/features/welcome.json';
|
||||
|
||||
import enUSErrors from './locales/en-US/messages/errors.json';
|
||||
import enUSSuccess from './locales/en-US/messages/success.json';
|
||||
@@ -123,7 +125,8 @@ export const translations = {
|
||||
persona: zhCNPersona,
|
||||
migration: zhCNMigration,
|
||||
command: zhCNCommand,
|
||||
subagent: zhCNSubagent
|
||||
subagent: zhCNSubagent,
|
||||
welcome: zhCNWelcome
|
||||
},
|
||||
messages: {
|
||||
errors: zhCNErrors,
|
||||
@@ -171,7 +174,8 @@ export const translations = {
|
||||
persona: enUSPersona,
|
||||
migration: enUSMigration,
|
||||
command: enUSCommand,
|
||||
subagent: enUSSubagent
|
||||
subagent: enUSSubagent,
|
||||
welcome: enUSWelcome
|
||||
},
|
||||
messages: {
|
||||
errors: enUSErrors,
|
||||
|
||||
@@ -18,10 +18,15 @@ export interface menu {
|
||||
// 在组件中使用时需要通过t()函数进行翻译
|
||||
// 所有键名都使用 core.navigation.* 格式
|
||||
const sidebarItem: menu[] = [
|
||||
{
|
||||
title: 'core.navigation.welcome',
|
||||
icon: 'mdi-hand-wave-outline',
|
||||
to: '/welcome',
|
||||
},
|
||||
{
|
||||
title: 'core.navigation.platforms',
|
||||
icon: 'mdi-robot',
|
||||
to: '/',
|
||||
to: '/platforms',
|
||||
},
|
||||
{
|
||||
title: 'core.navigation.providers',
|
||||
|
||||
@@ -3,13 +3,18 @@ const MainRoutes = {
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
},
|
||||
redirect: '/main/platforms',
|
||||
redirect: '/welcome',
|
||||
component: () => import('@/layouts/full/FullLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
name: 'MainPage',
|
||||
path: '/',
|
||||
component: () => import('@/views/PlatformPage.vue')
|
||||
component: () => import('@/views/WelcomePage.vue')
|
||||
},
|
||||
{
|
||||
name: 'Welcome',
|
||||
path: '/welcome',
|
||||
component: () => import('@/views/WelcomePage.vue')
|
||||
},
|
||||
{
|
||||
name: 'Extensions',
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div class="welcome-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<v-row class="px-4 py-3 pb-6">
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h1 font-weight-bold mb-2 d-flex align-center">
|
||||
{{ greetingText }} {{ greetingEmoji }}
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-0">
|
||||
{{ tm('subtitle') }}
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="px-4">
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm('onboard.title') }}
|
||||
</div>
|
||||
|
||||
<v-timeline align="start" side="end" density="compact" class="welcome-timeline" truncate-line="both">
|
||||
<v-timeline-item :dot-color="platformStepState === 'completed' ? 'success' : 'primary'"
|
||||
:icon="platformStepState === 'completed' ? 'mdi-check' : 'mdi-numeric-1'" fill-dot size="small">
|
||||
<div class="pl-2">
|
||||
<div class="text-h6 font-weight-bold mb-1">{{ tm('onboard.step1Title') }}</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step1Desc') }}</p>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn color="primary" variant="flat" rounded="pill" class="px-6" :loading="loadingPlatformDialog"
|
||||
@click="openPlatformDialog">
|
||||
{{ tm('onboard.configure') }}
|
||||
</v-btn>
|
||||
<div v-if="platformStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3">
|
||||
{{ tm('onboard.completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
|
||||
<v-timeline-item :dot-color="providerStepState === 'completed' ? 'success' : 'primary'"
|
||||
:icon="providerStepState === 'completed' ? 'mdi-check' : 'mdi-numeric-2'" fill-dot size="small">
|
||||
<div class="pl-2">
|
||||
<div class="text-h6 font-weight-bold mb-1"
|
||||
:class="{ 'text-medium-emphasis': platformStepState !== 'completed' }">{{ tm('onboard.step2Title')
|
||||
}}
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step2Desc') }}</p>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn color="primary" variant="flat" rounded="pill" class="px-6" @click="openProviderDialog">
|
||||
{{ tm('onboard.configure') }}
|
||||
</v-btn>
|
||||
<div v-if="providerStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3">
|
||||
{{ tm('onboard.completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</v-card>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="px-4 mt-4">
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm('resources.title') }}
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<!-- GitHub Card -->
|
||||
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column"
|
||||
href="https://github.com/AstrBotDevs/AstrBot/" target="_blank">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3">mdi-github</v-icon>
|
||||
<span class="text-h6 font-weight-bold">GitHub</span>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ tm('resources.githubDesc') }}
|
||||
</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<!-- Docs Card -->
|
||||
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column" href="https://docs.astrbot.app"
|
||||
target="_blank">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3">mdi-book-open-variant</v-icon>
|
||||
<span class="text-h6 font-weight-bold">{{ tm('resources.docsTitle') }}</span>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ tm('resources.docsDesc') }}
|
||||
</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="platformMetadata" :config_data="platformConfigData"
|
||||
@refresh-config="loadPlatformConfigBase" />
|
||||
<ProviderConfigDialog v-model="showProviderDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
type StepState = 'pending' | 'completed' | 'skipped';
|
||||
|
||||
const { tm } = useModuleI18n('features/welcome');
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
|
||||
const showAddPlatformDialog = ref(false);
|
||||
const showProviderDialog = ref(false);
|
||||
const loadingPlatformDialog = ref(false);
|
||||
|
||||
const platformMetadata = ref<Record<string, any>>({});
|
||||
const platformConfigData = ref<Record<string, any>>({});
|
||||
const platformCountBeforeOpen = ref(0);
|
||||
const providerCountBeforeOpen = ref(0);
|
||||
|
||||
const platformStepState = ref<StepState>('pending');
|
||||
const providerStepState = ref<StepState>('pending');
|
||||
|
||||
const springFestivalDates: Record<number, string> = {
|
||||
2025: '01-29',
|
||||
2026: '02-17',
|
||||
2027: '02-06',
|
||||
2028: '01-26',
|
||||
2029: '02-13',
|
||||
2030: '02-03'
|
||||
}
|
||||
|
||||
function isSpringFestival() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const dateStr = springFestivalDates[year];
|
||||
|
||||
if (!dateStr) return false;
|
||||
|
||||
const [month, day] = dateStr.split('-').map(Number);
|
||||
const festivalDate = new Date(year, month - 1, day);
|
||||
|
||||
const start = new Date(festivalDate);
|
||||
start.setDate(festivalDate.getDate() - 5);
|
||||
|
||||
const end = new Date(festivalDate);
|
||||
end.setDate(festivalDate.getDate() + 5);
|
||||
|
||||
// start of day for comparison
|
||||
const nowTime = now.setHours(0, 0, 0, 0);
|
||||
const startTime = start.setHours(0, 0, 0, 0);
|
||||
const endTime = end.setHours(0, 0, 0, 0);
|
||||
|
||||
return nowTime >= startTime && nowTime <= endTime;
|
||||
}
|
||||
|
||||
function isExactSpringFestivalDay() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const dateStr = springFestivalDates[year];
|
||||
|
||||
if (!dateStr) return false;
|
||||
|
||||
const [month, day] = dateStr.split('-').map(Number);
|
||||
const festivalDate = new Date(year, month - 1, day);
|
||||
|
||||
const nowTime = new Date(now).setHours(0, 0, 0, 0);
|
||||
const festivalTime = festivalDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return nowTime === festivalTime;
|
||||
}
|
||||
|
||||
const greetingEmoji = computed(() => {
|
||||
if (isExactSpringFestivalDay()) {
|
||||
return '🧨';
|
||||
}
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 0 && hour < 5) {
|
||||
return '😴';
|
||||
}
|
||||
return '😊';
|
||||
});
|
||||
|
||||
const greetingText = computed(() => {
|
||||
if (isSpringFestival()) {
|
||||
return tm('greeting.newYear');
|
||||
}
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return tm('greeting.morning');
|
||||
if (hour < 18) return tm('greeting.afternoon');
|
||||
return tm('greeting.evening');
|
||||
});
|
||||
|
||||
async function loadPlatformConfigBase() {
|
||||
const res = await axios.get('/api/config/get');
|
||||
platformMetadata.value = res.data.data.metadata || {};
|
||||
platformConfigData.value = res.data.data.config || {};
|
||||
}
|
||||
|
||||
function getChatProvidersFromTemplatePayload(payload: any) {
|
||||
const providers = payload?.providers || [];
|
||||
const sources = payload?.provider_sources || [];
|
||||
const sourceMap = new Map();
|
||||
sources.forEach((s: any) => sourceMap.set(s.id, s.provider_type));
|
||||
|
||||
return providers.filter((provider: any) => {
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type === 'chat_completion';
|
||||
}
|
||||
if (provider.provider_source_id) {
|
||||
const type = sourceMap.get(provider.provider_source_id);
|
||||
if (type === 'chat_completion') return true;
|
||||
}
|
||||
return String(provider.type || '').includes('chat_completion');
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchChatProviders() {
|
||||
const response = await axios.get('/api/config/provider/template');
|
||||
if (response.data.status !== 'ok') {
|
||||
throw new Error(response.data.message || tm('onboard.providerLoadFailed'));
|
||||
}
|
||||
return getChatProvidersFromTemplatePayload(response.data.data);
|
||||
}
|
||||
|
||||
function pickDefaultProviderId(providers: any[]) {
|
||||
if (!providers.length) return '';
|
||||
const enabledProvider = providers.find((provider) => provider.enable !== false);
|
||||
return (enabledProvider || providers[0]).id || '';
|
||||
}
|
||||
|
||||
async function syncDefaultConfigProviderIfNeeded() {
|
||||
const providers = await fetchChatProviders();
|
||||
if (!providers.length) return;
|
||||
|
||||
const targetProviderId = pickDefaultProviderId(providers);
|
||||
if (!targetProviderId) return;
|
||||
|
||||
const configRes = await axios.get('/api/config/abconf', { params: { id: 'default' } });
|
||||
const configData = configRes.data?.data?.config || {};
|
||||
if (!configData.provider_settings) {
|
||||
configData.provider_settings = {};
|
||||
}
|
||||
|
||||
if (configData.provider_settings.default_provider_id === targetProviderId) return;
|
||||
|
||||
configData.provider_settings.default_provider_id = targetProviderId;
|
||||
|
||||
const updateRes = await axios.post('/api/config/astrbot/update', {
|
||||
conf_id: 'default',
|
||||
config: configData
|
||||
});
|
||||
if (updateRes.data.status !== 'ok') {
|
||||
throw new Error(updateRes.data.message || tm('onboard.providerUpdateFailed'));
|
||||
}
|
||||
|
||||
showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId }));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
if ((platformConfigData.value.platform || []).length > 0) {
|
||||
platformStepState.value = 'completed';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
if (providers.length > 0) {
|
||||
providerStepState.value = 'completed';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
async function openPlatformDialog() {
|
||||
loadingPlatformDialog.value = true;
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
platformCountBeforeOpen.value = (platformConfigData.value.platform || []).length;
|
||||
showAddPlatformDialog.value = true;
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.platformLoadFailed'));
|
||||
} finally {
|
||||
loadingPlatformDialog.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openProviderDialog() {
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
providerCountBeforeOpen.value = providers.length;
|
||||
showProviderDialog.value = true;
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.providerLoadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
watch(showAddPlatformDialog, async (visible, wasVisible) => {
|
||||
if (!wasVisible || visible) return;
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
const newCount = (platformConfigData.value.platform || []).length;
|
||||
if (newCount > platformCountBeforeOpen.value) {
|
||||
platformStepState.value = 'completed';
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.platformLoadFailed'));
|
||||
}
|
||||
});
|
||||
|
||||
watch(showProviderDialog, async (visible, wasVisible) => {
|
||||
if (!wasVisible || visible) return;
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
if (providers.length > providerCountBeforeOpen.value) {
|
||||
providerStepState.value = 'completed';
|
||||
await syncDefaultConfigProviderIfNeeded();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.providerUpdateFailed'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user