Compare commits

..

5 Commits

25 changed files with 519 additions and 1003 deletions

View File

@@ -75,10 +75,6 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
### One-Click Deployment
> [!NOTE]
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
```bash
@@ -87,17 +83,20 @@ astrbot init # Only execute this command for the first time to initialize the en
astrbot run
```
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
> AstrBot requires Python 3.12 or later. The `--python 3.12` option ensures that `uv` creates the tool environment with Python 3.12.
> [!NOTE]
> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
Update `astrbot`:
```bash
uv tool upgrade astrbot --python 3.12
```
### One-Click Cloud Deployment (RainYun)
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
> [!WARNING]
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line.
### Docker Deployment
@@ -105,6 +104,12 @@ For users familiar with containers and looking for a more stable, production-rea
Please refer to the official documentation: [Deploy AstrBot with Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
### Deploy on RainYun
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### Desktop Application Deployment
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
@@ -137,18 +142,6 @@ yay -S astrbot-git
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
## ❤️ Sponsors
Welcome to sponsor us via [Afdian](https://afdian.com/a/astrbot_team) or [contact us](mailto:community@astrbot.app).
<p align="center">
<a target="_blank" href="https://astrbot.app/#/sponsors">
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
</a>
</p>
## Supported Messaging Platforms
Connect AstrBot to your favorite chat platform.
@@ -212,6 +205,13 @@ Connect AstrBot to your favorite chat platform.
| Xiaomi MiMo TTS | Text-to-Speech Services |
| Volcano Engine TTS | Text-to-Speech Services |
## ❤️ Sponsors
<p align="center">
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
</p>
## ❤️ Contributing
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
@@ -235,7 +235,22 @@ pre-commit install
### QQ Groups
We have 15+ chat groups, please see: [Community](https://docs.astrbot.app/community.html) for details.
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
- Group 5: 822130018 (Full)
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 12: 916228568 (Full)
- Group 13: 1092185289
- Group 14: 1103419483
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811
### Discord Server
@@ -249,10 +264,9 @@ Special thanks to all Contributors and plugin developers for their contributions
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
</a>
Open Source Friends ❤️
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework
- [Mai-with-u/MaiBot](https://github.com/Mai-with-u/MaiBot) - The powerful "digital life" in your QQ!
## ⭐ Star History

View File

@@ -369,12 +369,6 @@ class AuthService:
if not new_pwd and not new_username:
return self.error("新用户名和新密码不能同时为空")
username_to_save = None
if new_username is not None and new_username != "":
if not isinstance(new_username, str) or len(new_username.strip()) < 3:
return self.error("用户名长度至少3位")
username_to_save = new_username.strip()
if new_pwd:
if not isinstance(new_pwd, str):
return self.error("新密码无效")
@@ -390,8 +384,8 @@ class AuthService:
await set_password_change_required(self.db, self.config, False)
if is_totp_enabled(self.config):
await revoke_user_trusted_devices(self.db)
if username_to_save:
self.config["dashboard"]["username"] = username_to_save
if new_username:
self.config["dashboard"]["username"] = new_username
self.config.save_config()

View File

@@ -160,6 +160,10 @@
content: "\F0B79";
}
.mdi-chat-outline::before {
content: "\F0EDE";
}
.mdi-chat-processing::before {
content: "\F0B7B";
}
@@ -404,10 +408,6 @@
content: "\F0209";
}
.mdi-eye-outline::before {
content: "\F06D0";
}
.mdi-eyedropper::before {
content: "\F020A";
}

View File

@@ -2,7 +2,11 @@
<div
v-if="props.active"
class="chat-ui"
:class="{ 'is-dark': isDark, 'sidebar-collapsed': isSidebarCollapsed }"
:class="{
'is-dark': isDark,
'sidebar-collapsed': isSidebarCollapsed,
'dashboard-layout': !props.chatboxMode,
}"
>
<v-navigation-drawer
v-model="chatSidebarDrawer"
@@ -169,160 +173,6 @@
</section>
</div>
<div class="sidebar-footer">
<StyledMenu
location="top start"
offset="10"
:close-on-content-click="false"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="settings-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
>
<Settings
:size="20"
:class="['sidebar-action-icon', { 'mr-2': !isSidebarCollapsed }]"
/>
<span v-if="!isSidebarCollapsed">{{
t("core.common.settings")
}}</span>
</v-btn>
</template>
<div class="settings-menu-content">
<v-menu
location="end"
offset="8"
open-on-hover
:close-on-content-click="true"
>
<template #activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item settings-menu-item"
rounded="md"
>
<template #prepend>
<Cable :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
tm("transport.title")
}}</v-list-item-title>
<template #append>
<span class="settings-menu-value">{{
currentTransportLabel
}}</span>
<ChevronRight :size="18" class="styled-menu-lucide-icon" />
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="item in transportOptions"
:key="item.value"
class="styled-menu-item"
:class="{
'styled-menu-item-active': transportMode === item.value,
}"
rounded="md"
@click="transportMode = item.value"
>
<v-list-item-title>{{
tm(item.labelKey)
}}</v-list-item-title>
<template #append>
<Check
v-if="transportMode === item.value"
:size="18"
class="styled-menu-lucide-icon"
/>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-menu
location="end"
offset="8"
open-on-hover
:close-on-content-click="true"
>
<template #activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item settings-menu-item"
rounded="md"
>
<template #prepend>
<Languages :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
t("core.common.language")
}}</v-list-item-title>
<template #append>
<span class="settings-menu-value">{{
currentLanguage?.label || locale
}}</span>
<ChevronRight :size="18" class="styled-menu-lucide-icon" />
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languageOptions"
:key="lang.value"
class="styled-menu-item"
:class="{
'styled-menu-item-active': locale === lang.value,
}"
rounded="md"
@click="switchLanguage(lang.value as Locale)"
>
<template #prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.label }}</v-list-item-title>
<template #append>
<Check
v-if="locale === lang.value"
:size="18"
class="styled-menu-lucide-icon"
/>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-list-item
class="styled-menu-item settings-menu-item"
rounded="md"
@click="toggleTheme"
>
<template #prepend>
<Sun
v-if="isDark"
:size="18"
class="styled-menu-lucide-icon"
/>
<Moon v-else :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
isDark ? tm("modes.lightMode") : tm("modes.darkMode")
}}</v-list-item-title>
</v-list-item>
</div>
</StyledMenu>
</div>
</v-navigation-drawer>
<main
@@ -551,20 +401,12 @@ import { useDisplay } from "vuetify";
import { isAxiosError } from "axios";
import {
Box,
Cable,
Check,
ChevronRight,
Languages,
Moon,
PanelLeft,
Pencil,
Settings,
SquarePen,
Sun,
Trash2,
} from "@lucide/vue";
import { chatApi, providerApi } from "@/api/v1";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ProjectDialog, {
type ProjectFormData,
} from "@/components/chat/ProjectDialog.vue";
@@ -592,12 +434,7 @@ import { useProjects } from "@/composables/useProjects";
import { useChatHeaderStore } from "@/stores/chatHeader";
import { useCustomizerStore } from "@/stores/customizer";
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
import {
useI18n,
useLanguageSwitcher,
useModuleI18n,
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import {
contextLimit,
@@ -621,8 +458,6 @@ const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const toast = useToast();
const { languageOptions, currentLanguage, switchLanguage, locale } =
useLanguageSwitcher();
const {
sessions,
currSessionId,
@@ -789,20 +624,6 @@ const transportMode = ref<TransportMode>(
? "websocket"
: "sse",
);
const transportOptions: Array<{ value: TransportMode; labelKey: string }> = [
{ value: "sse", labelKey: "transport.sse" },
{ value: "websocket", labelKey: "transport.websocket" },
];
const currentTransportLabel = computed(() =>
tm(
transportOptions.find((item) => item.value === transportMode.value)
?.labelKey || "transport.sse",
),
);
watch(transportMode, (mode) => {
localStorage.setItem("chat.transportMode", mode);
});
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
const canSend = computed(
@@ -1657,9 +1478,6 @@ async function stopCurrentSession() {
}
}
function toggleTheme() {
customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark");
}
</script>
<style scoped>
@@ -1701,6 +1519,10 @@ function toggleTheme() {
--chat-section-label: rgba(255, 255, 255, 0.5);
}
.chat-ui.dashboard-layout .chat-main {
padding-top: 0;
}
.chat-sidebar {
top: 0 !important;
height: 100vh !important;
@@ -1853,8 +1675,7 @@ function toggleTheme() {
flex: 0 0 auto;
}
.new-chat-btn,
.settings-btn {
.new-chat-btn {
color: rgb(var(--v-theme-on-surface));
border-radius: 8px;
}
@@ -1869,8 +1690,7 @@ function toggleTheme() {
margin-right: 12px !important;
}
.new-chat-btn,
.settings-btn {
.new-chat-btn {
width: 100%;
min-height: 36px;
height: 36px;
@@ -1886,13 +1706,11 @@ function toggleTheme() {
margin-bottom: 2px;
}
.new-chat-btn:not(.icon-only),
.settings-btn:not(.icon-only) {
.new-chat-btn:not(.icon-only) {
padding-inline: 10px;
}
.new-chat-btn.icon-only,
.settings-btn.icon-only {
.new-chat-btn.icon-only {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
@@ -1901,27 +1719,19 @@ function toggleTheme() {
justify-content: center;
}
.chat-sidebar.collapsed .new-chat-btn.icon-only :deep(.v-btn__content),
.chat-sidebar.collapsed .settings-btn.icon-only :deep(.v-btn__content) {
.chat-sidebar.collapsed .new-chat-btn.icon-only :deep(.v-btn__content) {
display: flex;
align-items: center;
justify-content: center;
}
.new-chat-btn :deep(.v-btn__content),
.settings-btn :deep(.v-btn__content) {
.new-chat-btn :deep(.v-btn__content) {
min-width: 0;
font-size: 14px;
line-height: 20px;
}
.chat-sidebar.collapsed .sidebar-footer {
display: flex;
justify-content: center;
}
.new-chat-btn:hover,
.settings-btn:hover {
.new-chat-btn:hover {
background: var(--chat-session-active-bg);
}
@@ -2038,72 +1848,6 @@ function toggleTheme() {
color: rgb(var(--v-theme-on-surface));
}
.sidebar-footer {
margin-top: auto;
padding: 10px 16px 14px;
}
.chat-sidebar.collapsed .sidebar-footer {
width: 56px;
box-sizing: border-box;
padding-inline: 10px;
}
.settings-menu-content {
min-width: 270px;
padding: 6px;
}
.settings-menu-item {
min-height: 42px;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__prepend) {
width: 28px;
margin-inline-end: 12px;
align-self: center;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__content) {
min-width: 0;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item-title) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__append) {
margin-inline-start: auto;
padding-inline-start: 18px;
gap: 8px;
align-self: center;
}
.styled-menu-lucide-icon {
flex: 0 0 auto;
color: currentcolor;
stroke-width: 2;
}
.settings-menu-value {
color: var(--chat-muted);
font-size: 12px;
margin-right: 4px;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.language-flag {
display: inline-block;
width: 20px;
margin-right: 8px;
}
.chat-main {
flex: 1;
min-width: 0;
@@ -2307,4 +2051,9 @@ kbd {
padding: 0;
}
}
.chat-ui.dashboard-layout .chat-sidebar {
top: 0 !important;
height: 100% !important;
}
</style>

View File

@@ -180,7 +180,7 @@ const { success: toastSuccess, error: toastError } = useToast();
const variant = computed(() => props.variant);
const menuLocation = computed(() =>
props.variant === "header" ? "bottom start" : "top",
props.variant === "header" ? "bottom end" : "top",
);
const selectedProvider = computed(() =>

View File

@@ -45,7 +45,7 @@ localStorage.setItem('uiTheme', uiTheme);
const config: ConfigProps = {
Sidebar_drawer: true,
Customizer_drawer: false,
mini_sidebar: false,
mini_sidebar: true,
fontTheme: 'Roboto',
uiTheme,
themeMode,

View File

@@ -20,6 +20,23 @@
},
"openapi": {
"title": "OpenAPI"
},
"resources": {
"title": "About"
}
},
"resources": {
"changelog": {
"subtitle": "Review changes in the current and previous versions."
},
"documentation": {
"subtitle": "Open the official AstrBot documentation."
},
"faq": {
"subtitle": "Read common questions and troubleshooting notes."
},
"github": {
"subtitle": "Visit the AstrBot GitHub repository."
}
},
"systemConfig": {
@@ -58,6 +75,12 @@
},
"network": {
"title": "Network",
"transport": {
"title": "Chat Transport Mode",
"subtitle": "Choose the connection method Chat uses for streaming messages. Stored locally in this browser.",
"sse": "SSE",
"websocket": "WebSocket"
},
"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.",

View File

@@ -20,6 +20,23 @@
},
"openapi": {
"title": "OpenAPI"
},
"resources": {
"title": "О программе"
}
},
"resources": {
"changelog": {
"subtitle": "Просмотреть изменения текущей и предыдущих версий."
},
"documentation": {
"subtitle": "Открыть официальную документацию AstrBot."
},
"faq": {
"subtitle": "Посмотреть частые вопросы и советы по устранению проблем."
},
"github": {
"subtitle": "Открыть репозиторий AstrBot на GitHub."
}
},
"systemConfig": {
@@ -58,6 +75,12 @@
},
"network": {
"title": "Сеть",
"transport": {
"title": "Транспорт чата",
"subtitle": "Выберите способ подключения, который чат использует для потоковой передачи сообщений. Настройка хранится локально в браузере.",
"sse": "SSE",
"websocket": "WebSocket"
},
"githubProxy": {
"title": "Зеркало GitHub",
"subtitle": "Адрес для ускорения загрузки плагинов и обновлений AstrBot. Особенно актуально для пользователей из Китая. Все адреса предоставляются как есть, если обновление не удается — проверьте доступность выбранного зеркала.",

View File

@@ -20,6 +20,23 @@
},
"openapi": {
"title": "OpenAPI"
},
"resources": {
"title": "关于"
}
},
"resources": {
"changelog": {
"subtitle": "查看当前版本和历史版本的更新内容。"
},
"documentation": {
"subtitle": "打开 AstrBot 官方文档。"
},
"faq": {
"subtitle": "查看常见问题与排障说明。"
},
"github": {
"subtitle": "访问 AstrBot GitHub 仓库。"
}
},
"systemConfig": {
@@ -58,6 +75,12 @@
},
"network": {
"title": "网络",
"transport": {
"title": "通信传输模式",
"subtitle": "选择聊天消息流式传输时使用的连接方式,设置保存在浏览器本地。",
"sse": "SSE",
"websocket": "WebSocket"
},
"githubProxy": {
"title": "GitHub 加速地址",
"subtitle": "设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效。所有地址均不保证稳定性,如果在更新插件/项目时出现报错,请首先检查加速地址是否能正常使用。",

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { RouterView, useRoute } from "vue-router";
import { ref, onMounted, computed, watch } from "vue";
import { ref, onMounted, computed } from "vue";
import VerticalSidebarVue from "./vertical-sidebar/VerticalSidebar.vue";
import VerticalHeaderVue from "./vertical-header/VerticalHeader.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 { useCommonStore } from "@/stores/common";
@@ -18,27 +17,13 @@ const commonStore = useCommonStore();
const { locale } = useI18n();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
const isCurrentChatRoute = computed(
() => route.path === "/chat" || route.path.startsWith("/chat/"),
);
const isPluginPageRoute = computed(
() => route.path.startsWith("/plugin-page/"),
);
const isFullScreenRoute = computed(
() => isCurrentChatRoute.value || isPluginPageRoute.value,
);
const shouldMountChat = ref(isCurrentChatRoute.value);
const showSidebar = computed(() => !isCurrentChatRoute.value);
const isFullScreenRoute = computed(() => isPluginPageRoute.value);
const showFirstNoticeDialog = ref(false);
watch(isCurrentChatRoute, (isChatRoute) => {
if (isChatRoute) {
shouldMountChat.value = true;
}
});
const maybeShowFirstNotice = async () => {
if (localStorage.getItem(FIRST_NOTICE_SEEN_KEY) === "1") {
return;
@@ -107,18 +92,11 @@ onMounted(() => {
style="z-index: 9999; position: absolute; opacity: 0.3"
/>
<VerticalHeaderVue />
<VerticalSidebarVue v-if="showSidebar" />
<v-main
:class="{ 'chat-main': isCurrentChatRoute }"
:style="{
height: isCurrentChatRoute ? '100vh' : undefined,
overflow: isCurrentChatRoute ? 'hidden' : undefined,
}"
>
<VerticalSidebarVue />
<v-main>
<v-container
fluid
class="page-wrapper"
:class="{ 'chat-mode-container': isCurrentChatRoute }"
:style="{
height: isFullScreenRoute ? '100%' : 'calc(100% - 8px)',
padding: isFullScreenRoute ? '0' : undefined,
@@ -129,18 +107,10 @@ onMounted(() => {
:style="{
height: '100%',
width: '100%',
overflow: isCurrentChatRoute ? 'hidden' : undefined,
position: isPluginPageRoute ? 'relative' : undefined,
}"
>
<div
v-if="shouldMountChat"
v-show="isCurrentChatRoute"
style="height: 100%; width: 100%; overflow: hidden"
>
<Chat :active="isCurrentChatRoute" />
</div>
<RouterView v-if="!isCurrentChatRoute" />
<RouterView />
</div>
</v-container>
</v-main>
@@ -154,14 +124,3 @@ onMounted(() => {
</v-locale-provider>
</template>
<style scoped>
.chat-mode-container {
min-height: unset !important;
height: 100% !important;
overflow: hidden !important;
}
.chat-main {
padding-top: 0 !important;
}
</style>

View File

@@ -13,7 +13,7 @@ import "highlight.js/styles/github.css";
import { useI18n } from "@/i18n/composables";
import { router } from "@/router";
import { useRoute } from "vue-router";
import { useDisplay, useTheme } from "vuetify";
import { useTheme } from "vuetify";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import { useLanguageSwitcher } from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
@@ -29,11 +29,8 @@ const customizer = useCustomizerStore();
const commonStore = useCommonStore();
const chatHeader = useChatHeaderStore();
const theme = useTheme();
const { lgAndUp } = useDisplay();
const { t } = useI18n();
const route = useRoute();
const LAST_BOT_ROUTE_KEY = "astrbot:last_bot_route";
const LAST_CHAT_ROUTE_KEY = "astrbot:last_chat_route";
const SHOW_PRE_RELEASES_KEY = "astrbot:updateDialog:showPreReleases";
let dialog = ref(false);
let accountWarning = ref(false);
@@ -124,21 +121,6 @@ const desktopUpdateStatus = ref("");
const isChatPath = computed(
() => route.path === "/chat" || route.path.startsWith("/chat/"),
);
const isDarkTheme = computed(
() => theme.global.current.value.dark || customizer.uiTheme.includes("Dark"),
);
const chatHeaderStyle = computed(() => {
if (!isChatPath.value) return undefined;
const sidebarWidth = lgAndUp.value
? customizer.chatSidebarCollapsed
? 56
: 280
: 0;
return {
left: `${sidebarWidth}px`,
width: `calc(100% - ${sidebarWidth}px)`,
};
});
const chatHeaderSubtitleText = computed(() => {
const title = chatHeader.title.trim();
const subtitle = chatHeader.subtitle.trim();
@@ -891,11 +873,7 @@ function openReleaseNotesDialog(body: string, tag: string) {
}
function handleLogoClick() {
if (isChatPath.value) {
aboutDialog.value = true;
} else {
router.push("/about");
}
router.push("/about");
}
getVersion();
@@ -911,107 +889,12 @@ onUnmounted(() => {
stopRestartReloadTimer();
});
// 视图模式切换
onMounted(() => {
// 初次加載時保存當前路由
if (typeof window !== "undefined") {
if (isChatPath.value) {
// 保存 chat ID
const parts = route.fullPath.split("/");
const sessionId = parts[2];
if (sessionId) {
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
console.log("Initial save chat ID:", sessionId);
}
} else {
// 保存 bot 路由(非 chat 頁面)
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, route.fullPath);
console.log("Initial save bot route:", route.fullPath);
}
}
});
// 监听 viewMode 变化,切换到 bot 模式时跳转到首页
// 保存 bot 模式的最後路由
// 監聽 route 變化,保存最後一次 bot 路由
watch(showPreReleases, (value) => {
if (typeof window === "undefined") return;
localStorage.setItem(SHOW_PRE_RELEASES_KEY, value ? "true" : "false");
});
watch(
() => route.fullPath,
(newPath) => {
if (typeof window === "undefined") return;
console.log("Route changed:", {
newPath,
isChat: isChatPath.value,
currentChatId: route.params.id,
});
try {
// 使用現有的 isChatPath 計算屬性來避免名稱衝突
const isChat = isChatPath.value; // 這裡使用已經計算好的 isChatPath
// ✅ bot只存「非 chat 頁」
if (!isChat) {
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
}
// ✅ chat只存 sessionId
if (isChat) {
const parts = newPath.split("/");
const sessionId = parts[2];
if (sessionId) {
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
}
}
} catch (e) {
console.error("Failed to save route:", e);
}
},
);
const currentMode = computed({
get: () => (isChatPath.value ? "chat" : "bot"),
set: (val: "chat" | "bot") => {
try {
// 檢查 window 和 sessionStorage 是否存在
if (
typeof window === "undefined" ||
typeof sessionStorage === "undefined"
) {
// 如果在非瀏覽器環境中,不做任何 sessionStorage 操作
console.warn("sessionStorage is not available in this environment");
return;
}
if (val === "chat") {
const lastSessionId = sessionStorage.getItem(LAST_CHAT_ROUTE_KEY);
router.push(lastSessionId ? `/chat/${lastSessionId}` : "/chat");
} else {
let lastBotRoute = sessionStorage.getItem(LAST_BOT_ROUTE_KEY) || "/";
if (lastBotRoute.startsWith("/chat")) {
lastBotRoute = "/";
}
router.push(lastBotRoute);
}
} catch (e) {
// 在受限隱私模式等環境中sessionStorage 操作可能會拋出 SecurityError
console.warn("Failed to access sessionStorage in currentMode setter:", e);
}
},
});
const mainMenuOpen = ref(false);
const nextMode = computed<"chat" | "bot">(() =>
isChatPath.value ? "bot" : "chat",
);
function switchMode() {
currentMode.value = nextMode.value;
mainMenuOpen.value = false;
}
// Merry Christmas! 🎄
const isChristmas = computed(() => {
@@ -1051,29 +934,21 @@ onMounted(async () => {
elevation="0"
height="50"
class="top-header"
:class="{
'chat-mode-header': isChatPath,
'chat-mode-header--dark': isChatPath && isDarkTheme,
}"
:absolute="isChatPath"
:style="chatHeaderStyle"
>
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
<!-- 桌面端 menu 按钮 -->
<v-btn
v-if="!isChatPath"
style="margin-left: 16px"
class="hidden-md-and-down"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)"
@click.stop="customizer.SET_SIDEBAR_DRAWER"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 menu 按钮 -->
<v-btn
v-if="!isChatPath"
class="hidden-lg-and-up ms-3"
icon
rounded="sm"
@@ -1084,7 +959,6 @@ onMounted(async () => {
</v-btn>
<div
v-if="!isChatPath"
class="logo-container"
:class="{
'mobile-logo': $vuetify.display.xs,
@@ -1118,6 +992,9 @@ onMounted(async () => {
</v-icon>
</v-btn>
<v-spacer />
<!-- 版本提示信息 - 在手机上隐藏 -->
<div
v-if="isChatPath"
class="chat-header-context"
@@ -1128,10 +1005,7 @@ onMounted(async () => {
</div>
</div>
<v-spacer />
<!-- 版本提示信息 - 在手机上隐藏 -->
<div v-if="!isChatPath" class="mr-4 hidden-xs">
<div v-else class="mr-4 hidden-xs">
<small v-if="hasNewVersion">
{{ t("core.header.version.hasNewVersion") }}
</small>
@@ -1140,33 +1014,16 @@ onMounted(async () => {
</small>
</div>
<div class="header-actions" :class="{ 'chat-header-actions': isChatPath }">
<!-- Bot/Chat mode switch - single button, hidden in chat mobile menu -->
<v-btn
v-if="!isChatPath || !$vuetify.display.smAndDown"
class="mode-switch-btn"
:class="{ 'mr-4 hidden-xs': !isChatPath }"
variant="text"
size="small"
rounded="sm"
@click="switchMode"
>
<v-icon start>{{ nextMode === "bot" ? "mdi-robot" : "mdi-chat" }}</v-icon>
{{ nextMode === "bot" ? "Bot" : "Chat" }}
</v-btn>
<div class="header-actions">
<!-- 功能菜单 -->
<StyledMenu v-model="mainMenuOpen" offset="12" location="bottom end">
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
size="small"
:class="[
'action-btn',
isChatPath ? 'chat-action-btn' : 'mr-4',
]"
:color="isChatPath ? undefined : 'var(--v-theme-surface)'"
:variant="isChatPath ? 'text' : 'flat'"
class="action-btn mr-4"
color="var(--v-theme-surface)"
variant="flat"
rounded="sm"
icon
>
@@ -1174,29 +1031,6 @@ onMounted(async () => {
</v-btn>
</template>
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
<template
v-if="
(isChatPath && $vuetify.display.smAndDown) ||
(!isChatPath && $vuetify.display.xs)
"
>
<div class="mobile-mode-switch-wrapper">
<v-btn
class="mobile-mode-switch-btn"
variant="text"
block
@click="switchMode"
>
<v-icon start>{{
nextMode === "bot" ? "mdi-robot" : "mdi-chat"
}}</v-icon>
{{ nextMode === "bot" ? "Bot" : "Chat" }}
</v-btn>
</div>
<v-divider class="my-1" />
</template>
<!-- 语言切换分组 -->
<v-menu
open-on-click
@@ -2033,21 +1867,6 @@ onMounted(async () => {
flex: 0 1 auto;
}
.top-header.chat-mode-header {
background: #fdfcfc !important;
border-bottom: 0;
box-shadow: none !important;
}
.top-header.chat-mode-header.chat-mode-header--dark {
background: rgb(var(--v-theme-background)) !important;
}
.top-header.chat-mode-header .v-toolbar__content {
padding: 0 16px 0 20px;
background: transparent !important;
}
.chat-mobile-sidebar-toggle {
width: 32px !important;
height: 32px !important;
@@ -2071,13 +1890,15 @@ onMounted(async () => {
.chat-header-context {
min-width: 0;
max-width: clamp(180px, calc(100vw - 600px), 460px);
max-width: min(360px, 38vw);
align-self: stretch;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
padding: 5px 18px 5px 0;
padding: 5px 10px 5px 0;
overflow: hidden;
text-align: right;
}
.chat-header-context .provider-trigger--header {
@@ -2090,7 +1911,7 @@ onMounted(async () => {
.chat-header-context .provider-trigger-title {
min-width: 0;
max-width: min(300px, 52vw);
max-width: min(240px, 30vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2117,41 +1938,6 @@ onMounted(async () => {
align-items: center;
}
.chat-header-actions {
gap: 4px;
margin-right: 0;
}
.mode-switch-btn {
margin: 0;
border: 0;
color: rgb(var(--v-theme-on-surface));
font-size: 14px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
box-shadow: none;
}
.mode-switch-btn {
min-width: 62px;
background: transparent !important;
padding: 0 6px;
}
.mode-switch-btn .v-btn__overlay {
opacity: 0 !important;
}
.mode-switch-btn .v-icon {
font-size: 19px;
}
.chat-action-btn {
margin-right: 0;
color: rgb(var(--v-theme-on-surface));
}
/* 响应式布局样式 */
.logo-container {
margin-left: 10px;
@@ -2240,30 +2026,6 @@ onMounted(async () => {
opacity: 0.85;
}
.mobile-mode-switch-wrapper {
display: flex;
justify-content: center;
padding: 8px 12px 4px;
}
.mobile-mode-switch-btn {
width: 100%;
justify-content: flex-start;
color: rgb(var(--v-theme-on-surface));
font-size: 14px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.mobile-mode-switch-btn .v-icon {
font-size: 19px;
}
.mobile-mode-switch-btn .v-btn__overlay {
opacity: 0 !important;
}
/* 移动端对话框标题样式 */
.mobile-card-title {
display: flex;
@@ -2472,17 +2234,8 @@ onMounted(async () => {
font-size: 16px;
}
.chat-header-actions .chat-action-btn,
.chat-header-actions .mode-switch-btn {
margin-right: 0;
}
.top-header.chat-mode-header .v-toolbar__content {
padding: 0 12px 0 14px;
}
.chat-header-context {
max-width: calc(100vw - 92px);
max-width: calc(100vw - 210px);
padding-right: 8px;
}

View File

@@ -1,17 +1,15 @@
<script setup>
import { useI18n } from '@/i18n/composables';
import { useCustomizerStore } from '@/stores/customizer';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const props = defineProps({ item: Object, level: Number });
const props = defineProps({ item: Object, level: Number, rail: Boolean });
const { t } = useI18n();
const customizer = useCustomizerStore();
const route = useRoute();
const itemStyle = computed(() => {
const lvl = props.level ?? 0;
const indent = customizer.mini_sidebar ? '0px' : `${lvl * 24}px`;
const indent = props.rail ? '0px' : `${lvl * 24}px`;
return { '--indent-padding': indent };
});
@@ -22,7 +20,11 @@ const isItemActive = computed(() => {
const [path, hash] = props.item.to.split('#');
return route.path === path && route.hash === `#${hash}`;
}
return route.path === props.item.to;
const targetPath = props.item.to.replace(/\/$/, '') || '/';
if (targetPath === '/') {
return route.path === targetPath;
}
return route.path === targetPath || route.path.startsWith(`${targetPath}/`);
});
const itemTitle = computed(() => {
@@ -33,9 +35,19 @@ const itemTitle = computed(() => {
</script>
<template>
<v-list-group v-if="item.children" :value="item.title" :class="{ 'group-bordered': customizer.mini_sidebar }">
<template v-slot:activator="{ props }">
<v-list-item v-bind="props" rounded class="mb-1" color="secondary" :prepend-icon="item.icon"
<v-list-group v-if="item.children" :value="item.title" :class="{ 'rail-group': rail }">
<template v-slot:activator="{ props: groupProps }">
<v-tooltip v-if="rail" location="right" :text="itemTitle" open-delay="180">
<template v-slot:activator="{ props: tooltipProps }">
<v-list-item v-bind="{ ...groupProps, ...tooltipProps }" rounded class="mb-1" color="secondary"
:prepend-icon="item.icon" :style="{ '--indent-padding': '0px' }" :aria-label="itemTitle">
<v-list-item-title style="font-size: 14px; font-weight: 500; line-height: 1.2; word-break: break-word;">
{{ itemTitle }}
</v-list-item-title>
</v-list-item>
</template>
</v-tooltip>
<v-list-item v-else v-bind="groupProps" rounded class="mb-1" color="secondary" :prepend-icon="item.icon"
:style="{ '--indent-padding': '0px' }">
<v-list-item-title style="font-size: 14px; font-weight: 500; line-height: 1.2; word-break: break-word;">
{{ itemTitle }}
@@ -45,10 +57,33 @@ const itemTitle = computed(() => {
<!-- children -->
<template v-for="(child, index) in item.children" :key="child.title || child.to || `child-${index}`">
<NavItem :item="child" :level="(level || 0) + 1" />
<NavItem :item="child" :level="(level || 0) + 1" :rail="rail" />
</template>
</v-list-group>
<v-tooltip v-else-if="rail" location="right" :text="itemTitle" open-delay="180">
<template v-slot:activator="{ props: tooltipProps }">
<v-list-item v-bind="tooltipProps" :to="item.type === 'external' ? '' : item.to"
:href="item.type === 'external' ? item.to : ''" :active="isItemActive" rounded class="mb-1"
color="secondary" :disabled="item.disabled" :target="item.type === 'external' ? '_blank' : ''"
:style="itemStyle" :aria-label="itemTitle">
<template v-slot:prepend>
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
</template>
<v-list-item-title style="font-size: 14px;">{{ itemTitle }}</v-list-item-title>
<v-list-item-subtitle v-if="item.subCaption" class="text-caption mt-n1 hide-menu">
{{ item.subCaption }}
</v-list-item-subtitle>
<template v-slot:append v-if="item.chip">
<v-chip :color="item.chipColor" class="sidebarchip hide-menu" :size="item.chipIcon ? 'small' : 'default'"
:variant="item.chipVariant" :prepend-icon="item.chipIcon">
{{ item.chip }}
</v-chip>
</template>
</v-list-item>
</template>
</v-tooltip>
<v-list-item v-else :to="item.type === 'external' ? '' : item.to" :href="item.type === 'external' ? item.to : ''"
:active="isItemActive" rounded class="mb-1" color="secondary" :disabled="item.disabled"
:target="item.type === 'external' ? '_blank' : ''" :style="itemStyle">
@@ -69,10 +104,16 @@ const itemTitle = computed(() => {
</template>
<style>
/* 在折叠(mini)状态下,分组展开时给整个分组(母项+子项)加边框以便区分 */
.group-bordered.v-list-group--open {
border: 2px solid rgba(var(--v-theme-borderLight), 0.35);
border-radius: 8px;
background: rgba(var(--v-theme-borderLight), 0.04);
.rail-group {
border-radius: 12px;
transition: background-color 0.18s ease;
}
.rail-group.v-list-group--open {
background: rgba(var(--v-theme-primary), 0.06);
}
.rail-group.v-list-group--open > .v-list-group__items {
padding-bottom: 2px;
}
</style>

View File

@@ -1,18 +1,15 @@
<script setup>
import { ref, shallowRef, computed, onMounted, onUnmounted, watch } from 'vue';
import { useTheme } from 'vuetify';
import { useCustomizerStore } from '../../../stores/customizer';
import { useI18n } from '@/i18n/composables';
import sidebarItems, { MORE_GROUP_KEY } from './sidebarItem';
import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
import { usePluginSidebarItems } from '@/composables/usePluginSidebarItems';
const { t, locale } = useI18n();
const { t } = useI18n();
const customizer = useCustomizerStore();
const theme = useTheme();
const { pluginItems } = usePluginSidebarItems();
function buildSidebarMenu() {
@@ -105,159 +102,23 @@ onUnmounted(() => {
window.removeEventListener('sidebar-customization-changed', handleCustomEvent);
});
const showIframe = ref(false);
const starCount = ref(null);
// 更新日志对话框
const changelogDialog = ref(false);
const sidebarWidth = ref(235);
const minSidebarWidth = 200;
const maxSidebarWidth = 300;
const isResizing = ref(false);
const isDark = computed(() => customizer.uiTheme === 'PurpleThemeDark');
const themeColors = computed(() => theme.current.value.colors);
const iframeBackground = computed(() => isDark.value ? themeColors.value.surface || 'white' : 'white');
const dragHeaderBackground = computed(() => isDark.value ? themeColors.value.mcpCardBg || themeColors.value.surface || 'white' : '#f0f0f0');
const frameBorder = computed(() => `1px solid ${isDark.value ? (themeColors.value.borderLight || '#ccc') : '#ccc'}`);
const isMobile = window.innerWidth < 768;
const isRailSidebar = !isMobile;
if (isMobile) {
customizer.Sidebar_drawer = false;
} else {
customizer.SET_MINI_SIDEBAR(true);
}
const dragPos = ref({ left: '', top: '' });
const iframeStyle = computed(() => {
const base = isMobile
? { position: 'fixed', top: '10%', left: '0%', width: '100%', height: '80%', zIndex: '1002' }
: { position: 'fixed', bottom: '16px', right: '16px', width: '490px', height: '640px', zIndex: '10000000' };
const pos = dragPos.value.left ? { left: dragPos.value.left, top: dragPos.value.top, bottom: 'auto', right: 'auto' } : {};
return {
...base,
...pos,
minWidth: '300px',
minHeight: '200px',
background: iframeBackground.value,
resize: 'both',
overflow: 'auto',
borderRadius: '12px',
boxShadow: isDark.value ? '0px 4px 16px rgba(0, 0, 0, 0.5)' : '0px 4px 12px rgba(0, 0, 0, 0.1)',
};
});
const iframeInnerStyle = computed(() => ({
width: '100%',
height: 'calc(100% - 66px)',
border: 'none',
borderBottomLeftRadius: '12px',
borderBottomRightRadius: '12px',
filter: isDark.value ? 'invert(0.88) hue-rotate(180deg)' : 'none',
}));
const dragHeaderStyle = computed(() => ({
width: '100%',
padding: '8px',
background: dragHeaderBackground.value,
borderBottom: frameBorder.value,
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'move'
}));
function toggleIframe() {
showIframe.value = !showIframe.value;
}
function openIframeLink(url) {
if (typeof window !== 'undefined') {
let url_ = url || "https://docs.astrbot.app";
window.open(url_, "_blank");
}
}
function openFaqLink() {
const faqUrl = locale.value === 'en-US'
? 'https://docs.astrbot.app/en/faq.html'
: 'https://docs.astrbot.app/faq.html';
openIframeLink(faqUrl);
}
let offsetX = 0;
let offsetY = 0;
let isDragging = false;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function startDrag(clientX, clientY) {
isDragging = true;
const dm = document.getElementById('draggable-iframe');
const rect = dm.getBoundingClientRect();
offsetX = clientX - rect.left;
offsetY = clientY - rect.top;
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}
function onMouseDown(event) {
startDrag(event.clientX, event.clientY);
}
function onMouseMove(event) {
if (isDragging) {
moveAt(event.clientX, event.clientY);
}
}
function onMouseUp() {
endDrag();
}
function onTouchStart(event) {
if (event.touches.length === 1) {
const touch = event.touches[0];
startDrag(touch.clientX, touch.clientY);
}
}
function onTouchMove(event) {
if (isDragging && event.touches.length === 1) {
event.preventDefault();
const touch = event.touches[0];
moveAt(touch.clientX, touch.clientY);
}
}
function onTouchEnd() {
endDrag();
}
function moveAt(clientX, clientY) {
const dm = document.getElementById('draggable-iframe');
const newLeft = clamp(clientX - offsetX, 0, window.innerWidth - dm.offsetWidth);
const newTop = clamp(clientY - offsetY, 0, window.innerHeight - dm.offsetHeight);
// Sync dragged position to reactive variable
dragPos.value = { left: newLeft + 'px', top: newTop + 'px' };
}
function endDrag() {
isDragging = false;
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
}
function startSidebarResize(event) {
isResizing.value = true;
document.body.style.userSelect = 'none';
@@ -291,30 +152,6 @@ function startSidebarResize(event) {
document.addEventListener('mouseup', onMouseUpResize);
}
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
async function fetchStarCount() {
try {
const response = await fetch('https://cloud.astrbot.app/api/v1/github/repo-info');
const data = await response.json();
if (data.data && data.data.stargazers_count) {
starCount.value = data.data.stargazers_count;
console.debug('Fetched star count:', starCount.value);
}
} catch (error) {
console.debug('Failed to fetch star count:', error);
}
}
fetchStarCount();
// 打开更新日志对话框
function openChangelogDialog() {
changelogDialog.value = true;
}
</script>
<template>
@@ -326,46 +163,27 @@ function openChangelogDialog() {
app
class="leftSidebar"
:width="sidebarWidth"
:rail="customizer.mini_sidebar"
:rail="isRailSidebar"
>
<div class="sidebar-container">
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': customizer.mini_sidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': isRailSidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
<NavItem :item="item" class="leftPadding" />
<NavItem :item="item" class="leftPadding" :rail="isRailSidebar" />
</template>
</v-list>
<div class="sidebar-footer" v-if="!customizer.mini_sidebar">
<v-btn class="sidebar-footer-btn" size="small" variant="tonal" color="primary" to="/settings" prepend-icon="mdi-cog">
{{ t('core.navigation.settings') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-note-text-outline"
@click="openChangelogDialog">
{{ t('core.navigation.changelog') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-book-open-variant"
@click="toggleIframe">
{{ t('core.navigation.documentation') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-frequently-asked-questions"
@click="openFaqLink">
{{ t('core.navigation.faq') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-github"
@click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
{{ t('core.navigation.github') }}
<v-chip
v-if="starCount"
size="x-small"
variant="outlined"
class="ml-2"
style="font-weight: normal;"
>{{ formatNumber(starCount) }}</v-chip>
<div class="sidebar-footer">
<v-btn class="sidebar-footer-btn" :class="{ 'sidebar-footer-icon-btn': isRailSidebar }" :size="isRailSidebar ? 'default' : 'small'"
:variant="isRailSidebar ? 'text' : 'tonal'" :color="isRailSidebar ? undefined : 'primary'" to="/settings"
:prepend-icon="isRailSidebar ? undefined : 'mdi-cog'" :aria-label="t('core.navigation.settings')">
<v-icon v-if="isRailSidebar" icon="mdi-cog" />
<template v-else>{{ t('core.navigation.settings') }}</template>
<v-tooltip v-if="isRailSidebar" activator="parent" location="right" :text="t('core.navigation.settings')" open-delay="180" />
</v-btn>
</div>
</div>
<div
v-if="!customizer.mini_sidebar && customizer.Sidebar_drawer"
v-if="!isRailSidebar && !isMobile && customizer.Sidebar_drawer"
class="sidebar-resize-handle"
@mousedown="startSidebarResize"
:class="{ 'resizing': isResizing }"
@@ -373,44 +191,6 @@ function openChangelogDialog() {
</div>
</v-navigation-drawer>
<div
v-if="showIframe"
id="draggable-iframe"
:style="iframeStyle"
>
<div :style="dragHeaderStyle" @mousedown="onMouseDown" @touchstart="onTouchStart">
<div style="display: flex; align-items: center;">
<v-icon icon="mdi-cursor-move" />
<span style="margin-left: 8px;">{{ t('core.navigation.drag') }}</span>
</div>
<div style="display: flex; gap: 8px;">
<v-btn
icon
@click.stop="openIframeLink('https://docs.astrbot.app')"
@mousedown.stop
:style="{ borderRadius: '8px', border: frameBorder }"
>
<v-icon icon="mdi-open-in-new" />
</v-btn>
<v-btn
icon
@click.stop="toggleIframe"
@mousedown.stop
:style="{ borderRadius: '8px', border: frameBorder }"
>
<v-icon icon="mdi-close" />
</v-btn>
</div>
</div>
<iframe
src="https://docs.astrbot.app"
:style="iframeInnerStyle"
></iframe>
</div>
<!-- 更新日志对话框 -->
<ChangelogDialog v-model="changelogDialog" />
</template>
<style scoped>

View File

@@ -26,6 +26,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-hand-wave-outline',
to: '/welcome',
},
{
title: 'core.navigation.chat',
icon: 'mdi-star-outline',
to: '/chat',
},
{
title: 'core.navigation.platforms',
icon: 'mdi-robot',
@@ -44,17 +49,12 @@ const sidebarItem: menu[] = [
{
title: 'core.navigation.extension',
icon: 'mdi-puzzle',
to: '/extension#installed',
to: '/extension',
children: [
{
title: 'core.navigation.extensionTabs.installed',
icon: 'mdi-puzzle',
to: '/extension#installed'
},
{
title: 'core.navigation.extensionTabs.market',
icon: 'mdi-store',
to: '/extension#market'
to: '/extension'
},
{
title: 'core.navigation.extensionTabs.mcp',

View File

@@ -4,16 +4,14 @@ const ChatBoxRoutes = {
children: [
{
name: 'ChatBox',
path: '/chatbox',
path: '',
component: () => import('@/views/ChatBoxPage.vue')
},
{
path: ':conversationId',
name: 'ChatBoxDetail',
component: () => import('@/views/ChatBoxPage.vue'),
children: [
{
path: ':conversationId',
name: 'ChatBoxDetail',
component: () => import('@/views/ChatBoxPage.vue'),
props: true
}
]
props: true
}
]
};

View File

@@ -159,16 +159,9 @@ const MainRoutes = {
// },
{
name: 'Chat',
path: '/chat',
path: '/chat/:conversationId?',
component: () => import('@/views/ChatPage.vue'),
children: [
{
path: ':conversationId',
name: 'ChatDetail',
component: () => import('@/views/ChatPage.vue'),
props: true
}
]
props: true
},
{
name: 'Settings',

View File

@@ -65,6 +65,101 @@
.leftPadding {
margin-left: 0px;
}
.listitem {
padding: 10px !important;
.v-list-group__items .v-list-item,
.v-list-item {
width: 56px;
min-width: 56px;
max-width: 56px;
min-height: 50px;
margin-bottom: 8px !important;
margin-inline: auto;
padding-inline: 0 !important;
grid-template-areas: "prepend" !important;
grid-template-columns: 1fr !important;
justify-content: center;
place-items: center;
}
.rail-group {
width: 56px;
margin-bottom: 8px;
margin-inline: auto;
padding: 4px 0 2px;
.v-list-item {
width: 56px;
min-width: 56px;
max-width: 56px;
margin-bottom: 6px !important;
}
}
.v-list-item__prepend {
grid-area: prepend;
width: 100%;
min-width: 0;
padding: 0 !important;
justify-content: center;
margin-inline-end: 0;
.v-icon {
margin: 0 !important;
}
}
.v-list-item__spacer,
.v-list-item__append,
.v-list-item__content {
display: none;
}
}
.sidebar-container {
align-items: center;
}
.sidebar-footer {
width: 100%;
padding: 12px 10px 18px !important;
gap: 8px;
.sidebar-footer-icon-btn {
width: 56px !important;
min-width: 56px !important;
max-width: 56px !important;
height: 52px !important;
border-radius: 14px !important;
color: rgba(var(--v-theme-on-surface), 0.82);
margin-bottom: 0 !important;
padding: 0 !important;
justify-content: center !important;
text-align: center;
transition:
background-color 0.18s ease,
color 0.18s ease;
.v-btn__content {
width: 100%;
justify-content: center;
}
.v-icon {
font-size: 22px;
margin: 0 !important;
opacity: 0.98;
}
&:hover,
&.v-btn--active {
color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.09);
}
}
}
}
@media only screen and (min-width: 1170px) {
.mini-sidebar {
@@ -122,3 +217,16 @@
}
}
}
.v-navigation-drawer--rail.leftSidebar {
.sidebar-container .sidebar-footer {
.sidebar-footer-btn.sidebar-footer-icon-btn {
justify-content: center !important;
text-align: center;
.v-btn__content {
justify-content: center;
}
}
}
}

View File

@@ -10,6 +10,8 @@ import Chat from '@/components/chat/Chat.vue'
<style scoped>
.chat-container {
height: calc(100vh - 60px)
height: 100%;
min-height: calc(100vh - 66px);
overflow: hidden;
}
</style>
</style>

View File

@@ -390,7 +390,11 @@ const updateDialogPluginLogo = computed(() => {
</v-card>
</v-col>
<v-col v-if="activeTab === 'market'" cols="12" md="12">
<v-col
v-if="activeTab === 'installed' || activeTab === 'market'"
cols="12"
md="12"
>
<div class="d-flex align-center justify-center mt-4 mb-4 gap-4">
<v-btn
variant="text"

View File

@@ -11,7 +11,10 @@
:key="item.id"
type="button"
class="settings-nav__item"
:class="{ 'settings-nav__item--active': activeSettingsSection === item.id }"
:class="{
'settings-nav__item--active': activeSettingsSection === item.id,
'settings-nav__item--divider': item.dividerBefore
}"
:aria-pressed="activeSettingsSection === item.id"
@click="activeSettingsSection = item.id"
>
@@ -147,6 +150,33 @@
<div class="settings-section__title">{{ tm('sections.network.title') }}</div>
</div>
<div class="settings-section__content">
<div class="settings-list-card">
<div class="settings-item">
<div class="settings-item__label">
<div class="settings-item__title">{{ tm('network.transport.title') }}</div>
<div class="settings-item__subtitle">{{ tm('network.transport.subtitle') }}</div>
</div>
<div class="settings-item__control settings-item__control--transport">
<v-btn-toggle
v-model="chatTransportMode"
mandatory
divided
density="compact"
color="primary"
class="transport-mode-toggle"
>
<v-btn
v-for="option in chatTransportOptions"
:key="option.value"
:value="option.value"
>
{{ option.title }}
</v-btn>
</v-btn-toggle>
</div>
</div>
</div>
<template v-if="!systemConfigLoading">
<div
v-for="group in networkSystemConfigGroups"
@@ -395,12 +425,39 @@
</div>
</div>
</section>
<section id="settings-resources" class="settings-section" v-show="activeSettingsSection === 'resources'">
<div class="settings-section__heading">
<div class="settings-section__title">{{ tm('sections.resources.title') }}</div>
</div>
<div class="settings-section__content">
<div class="settings-list-card">
<div
v-for="item in resourceItems"
:key="item.key"
class="settings-item"
>
<div class="settings-item__label">
<div class="settings-item__title">{{ item.title }}</div>
<div class="settings-item__subtitle">{{ item.subtitle }}</div>
</div>
<div class="settings-item__control">
<v-btn variant="tonal" @click="item.action()">
<v-icon class="mr-2">{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
<WaitingForRestart ref="wfr" />
<BackupDialog ref="backupDialog" />
<ChangelogDialog v-model="changelogDialog" />
<DashboardTwoFactorDialog
v-model="configSave2faDialogVisible"
:error-message="configSave2faError"
@@ -421,9 +478,10 @@ import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import BackupDialog from '@/components/shared/BackupDialog.vue';
import StorageCleanupPanel from '@/components/shared/StorageCleanupPanel.vue';
import DashboardTwoFactorDialog from '@/components/shared/DashboardTwoFactorDialog.vue';
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import { copyToClipboard } from '@/utils/clipboard';
import { useModuleI18n } from '@/i18n/composables';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { PurpleTheme } from '@/theme/LightTheme';
import { useToastStore } from '@/stores/toast';
@@ -431,6 +489,7 @@ import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
const { tm } = useModuleI18n('features/settings');
const { tm: tmMeta } = useModuleI18n('features/config-metadata');
const { t, locale } = useI18n();
const toastStore = useToastStore();
const confirmDialog = useConfirmDialog();
const theme = useTheme();
@@ -442,6 +501,11 @@ const getStoredColor = (key, fallback) => {
const primaryColor = ref(getStoredColor('themePrimary', PurpleTheme.colors.primary));
const secondaryColor = ref(getStoredColor('themeSecondary', PurpleTheme.colors.secondary));
const chatTransportMode = ref(
typeof window !== 'undefined' && localStorage.getItem('chat.transportMode') === 'websocket'
? 'websocket'
: 'sse'
);
const resolveThemes = () => {
if (theme?.themes?.value) return theme.themes.value;
@@ -476,6 +540,11 @@ watch(secondaryColor, (value) => {
applyThemeColors(primaryColor.value, value);
});
watch(chatTransportMode, (value) => {
if (typeof window === 'undefined') return;
localStorage.setItem('chat.transportMode', value === 'websocket' ? 'websocket' : 'sse');
});
const wfr = ref(null);
const backupDialog = ref(null);
const apiKeys = ref([]);
@@ -497,6 +566,7 @@ const configSave2faRotationHint = ref('');
const configSavePendingData = ref(null);
const systemConfigAutoSaveTimer = ref(null);
const activeSettingsSection = ref('general');
const changelogDialog = ref(false);
const apiKeyExpiryOptions = computed(() => [
{ title: tm('apiKey.expiryOptions.day1'), value: 1 },
@@ -520,13 +590,61 @@ const availableScopes = [
{ value: 'skill', label: 'skill' }
];
const chatTransportOptions = computed(() => [
{ title: tm('network.transport.sse'), value: 'sse' },
{ title: tm('network.transport.websocket'), value: 'websocket' }
]);
const settingsNavItems = computed(() => [
{ id: 'general', label: tm('sections.general.title'), icon: 'mdi mdi-tune-variant' },
{ id: 'appearance', label: tm('sections.appearance.title'), icon: 'mdi mdi-palette-outline' },
{ id: 'network', label: tm('sections.network.title'), icon: 'mdi mdi-lan-connect' },
{ id: 'security', label: tm('sections.security.title'), icon: 'mdi mdi-shield-lock-outline' },
{ id: 'maintenance', label: tm('sections.maintenance.title'), icon: 'mdi mdi-tools' },
{ id: 'openapi', label: tm('sections.openapi.title'), icon: 'mdi mdi-api' }
{ id: 'openapi', label: tm('sections.openapi.title'), icon: 'mdi mdi-api' },
{ id: 'resources', label: tm('sections.resources.title'), icon: 'mdi mdi-information-outline', dividerBefore: true }
]);
const openExternalLink = (url) => {
if (typeof window === 'undefined') return;
window.open(url, '_blank', 'noopener,noreferrer');
};
const openFaqLink = () => {
openExternalLink(locale.value === 'en-US'
? 'https://docs.astrbot.app/en/faq.html'
: 'https://docs.astrbot.app/faq.html');
};
const resourceItems = computed(() => [
{
key: 'changelog',
title: t('core.navigation.changelog'),
subtitle: tm('resources.changelog.subtitle'),
icon: 'mdi-note-text-outline',
action: () => { changelogDialog.value = true; }
},
{
key: 'documentation',
title: t('core.navigation.documentation'),
subtitle: tm('resources.documentation.subtitle'),
icon: 'mdi-book-open-variant',
action: () => openExternalLink('https://docs.astrbot.app')
},
{
key: 'faq',
title: t('core.navigation.faq'),
subtitle: tm('resources.faq.subtitle'),
icon: 'mdi-frequently-asked-questions',
action: openFaqLink
},
{
key: 'github',
title: t('core.navigation.github'),
subtitle: tm('resources.github.subtitle'),
icon: 'mdi-github',
action: () => openExternalLink('https://github.com/AstrBotDevs/AstrBot')
}
]);
const configIncludedScopes = ['bot', 'provider'];
@@ -916,6 +1034,8 @@ onMounted(async () => {
activeSettingsSection.value = 'maintenance';
} else if (hash.includes('settings-openapi')) {
activeSettingsSection.value = 'openapi';
} else if (hash.includes('settings-resources')) {
activeSettingsSection.value = 'resources';
}
});
@@ -995,6 +1115,20 @@ onUnmounted(() => {
color: rgb(var(--v-theme-on-surface));
}
.settings-nav__item--divider {
margin-top: 13px;
}
.settings-nav__item--divider::after {
position: absolute;
top: -9px;
right: 0;
left: 0;
height: 1px;
background: var(--settings-divider);
content: "";
}
.settings-nav__item--active {
background: rgba(var(--v-theme-on-surface), 0.07);
color: rgb(var(--v-theme-on-surface));
@@ -1132,6 +1266,23 @@ onUnmounted(() => {
max-width: none;
}
.settings-item__control--transport {
max-width: 260px;
}
.transport-mode-toggle {
width: 100%;
overflow: hidden;
border: 1px solid var(--settings-border);
border-radius: 10px;
}
.transport-mode-toggle :deep(.v-btn) {
flex: 1 1 0;
min-width: 0;
text-transform: none;
}
.settings-item__control :deep(.v-btn) {
min-height: 36px;
border-radius: 10px;

View File

@@ -221,7 +221,7 @@ const togglePinnedExtension = (extension) => {
</script>
<template>
<v-tab-item v-show="activeTab === 'installed'">
<v-tab-item v-show="activeTab === 'installed' || activeTab === 'market'">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<h2 class="text-h2 mb-0">{{ tm("titles.installedAstrBotPlugins") }}</h2>

View File

@@ -184,7 +184,7 @@ const openMarketPluginDetail = (plugin) => {
</script>
<template>
<v-tab-item v-show="activeTab === 'market'">
<v-tab-item v-show="activeTab === 'installed' || activeTab === 'market'">
<div class="mb-6 pt-4 pb-4">
<div class="d-flex align-center" style="gap: 12px">
<div class="d-flex align-center" style="gap: 12px; min-width: 0">

View File

@@ -1360,105 +1360,6 @@ async def test_generated_password_requires_password_change_until_changed(
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("endpoint", "method"),
[
("/api/auth/account/edit", "post"),
("/api/v1/auth/account", "patch"),
],
)
@pytest.mark.parametrize("new_username", ["ab", " "])
async def test_account_edit_rejects_invalid_username(
app: FastAPIAppAdapter,
core_lifecycle_td: AstrBotCoreLifecycle,
endpoint: str,
method: str,
new_username: str,
):
original_dashboard_config = copy.deepcopy(
core_lifecycle_td.astrbot_config["dashboard"]
)
test_client = app.test_client()
current_username = core_lifecycle_td.astrbot_config["dashboard"]["username"]
current_password = _resolve_dashboard_password(core_lifecycle_td)
try:
login_response = await test_client.post(
"/api/auth/login",
json={"username": current_username, "password": current_password},
)
login_data = await login_response.get_json()
assert login_data["status"] == "ok"
headers = {"Authorization": f"Bearer {login_data['data']['token']}"}
payload = {
"password": current_password,
"new_password": "",
"confirm_password": "",
"new_username": new_username,
}
request = getattr(test_client, method)
response = await request(endpoint, headers=headers, json=payload)
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == "用户名长度至少3位"
assert (
core_lifecycle_td.astrbot_config["dashboard"]["username"]
== (original_dashboard_config["username"])
)
finally:
await _restore_dashboard_password_state(
core_lifecycle_td,
original_dashboard_config,
)
@pytest.mark.asyncio
async def test_account_edit_trims_valid_username(
app: FastAPIAppAdapter,
core_lifecycle_td: AstrBotCoreLifecycle,
):
original_dashboard_config = copy.deepcopy(
core_lifecycle_td.astrbot_config["dashboard"]
)
test_client = app.test_client()
current_username = core_lifecycle_td.astrbot_config["dashboard"]["username"]
current_password = _resolve_dashboard_password(core_lifecycle_td)
try:
login_response = await test_client.post(
"/api/auth/login",
json={"username": current_username, "password": current_password},
)
login_data = await login_response.get_json()
assert login_data["status"] == "ok"
headers = {"Authorization": f"Bearer {login_data['data']['token']}"}
response = await test_client.post(
"/api/auth/account/edit",
headers=headers,
json={
"password": current_password,
"new_password": "",
"confirm_password": "",
"new_username": " astrbot-admin ",
},
)
data = await response.get_json()
assert data["status"] == "ok"
assert core_lifecycle_td.astrbot_config["dashboard"]["username"] == (
"astrbot-admin"
)
finally:
await _restore_dashboard_password_state(
core_lifecycle_td,
original_dashboard_config,
)
@pytest.mark.asyncio
async def test_local_setup_can_skip_default_password_auth(
app: FastAPIAppAdapter,