Compare commits

..

5 Commits

Author SHA1 Message Date
Soulter
58b58aaf31 chore: log event loop watchdog startup at info 2026-07-07 00:20:17 +08:00
Soulter
a18054c8b4 fix: keep event loop watchdog alive after dump failures 2026-07-07 00:12:05 +08:00
Soulter
f683adc74c chore: use fixed event loop diagnostic defaults 2026-07-07 00:03:39 +08:00
Soulter
f5b08eb135 fix: write event loop watchdog dumps to rotating log 2026-07-06 23:59:43 +08:00
Soulter
54124979b0 feat: add event loop diagnostics 2026-07-06 23:26:57 +08:00
43 changed files with 915 additions and 968 deletions

View File

@@ -1,4 +1,4 @@
import logging
__version__ = "4.26.5"
__version__ = "4.26.4"
logger = logging.getLogger("astrbot")

View File

@@ -392,16 +392,10 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
class MCPClient:
def __init__(self) -> None:
# Initialize session and client objects
self.session: mcp.ClientSession | None = None
# Each connection runs in its own task so that anyio cancel scopes
# are always exited from the task that entered them, preventing
# RuntimeError: Attempted to exit cancel scope in a different task
self._connection_task: asyncio.Task | None = None
self._old_connection_tasks: list[asyncio.Task] = []
# Internal; managed exclusively by _run_connection.
self.exit_stack: AsyncExitStack | None = None
self.exit_stack = AsyncExitStack()
self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup
self.name: str | None = None
self.active: bool = True
@@ -409,67 +403,14 @@ class MCPClient:
self.server_errlogs: list[str] = []
self.running_event = asyncio.Event()
# Store connection config for reconnection
self._mcp_server_config: dict | None = None
self._server_name: str | None = None
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
self._reconnecting: bool = False
async def _run_connection(
self,
mcp_server_config: dict,
name: str,
ready: asyncio.Future,
) -> None:
"""Own the full lifetime of one MCP connection.
This coroutine is always run inside a dedicated asyncio.Task
(_connection_task). Because *this task* is the one that enters every
anyio cancel scope (via sse_client / streamablehttp_client), anyio's
_host_task check is always satisfied when the stack is later closed —
either in the task's own finally block (normal path) or when the task
is cancelled from outside (cleanup / reconnect path).
This avoids the
RuntimeError: Attempted to exit cancel scope in a different task
that previously occurred when aclose() was called from a different task
or from the asyncio async-generator GC finalizer.
"""
# Capture the stack in a local variable so that if self.exit_stack is
# overwritten by a concurrent _run_connection (during reconnect), this
# task's finally block still closes only the resources it opened.
stack = self.exit_stack = AsyncExitStack()
try:
try:
await self._do_connect(mcp_server_config, name)
except Exception as exc:
if not ready.done():
ready.set_exception(exc)
raise
else:
if not ready.done():
ready.set_result(None)
# Hold the connection open until cancelled.
await asyncio.Event().wait()
finally:
try:
await stack.aclose()
except Exception as e:
logger.debug(f"Error closing exit stack for {name}: {e}")
# Clear the instance reference only if it still points to this task's
# stack; a concurrent reconnect may have already replaced it.
if self.exit_stack is stack:
self.exit_stack = None
# Guard against the task exiting before ready was resolved.
if not ready.done():
ready.set_exception(RuntimeError("Connection task exited early"))
self._reconnecting: bool = False # For logging and debugging
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
"""Connect to MCP server by spawning a dedicated owner task.
The owner task (_connection_task) holds the AsyncExitStack and all
anyio cancel scopes for the lifetime of this connection. To disconnect,
cancel _connection_task — the finally block in _run_connection will call
aclose() from within the correct task context.
"""Connect to MCP server
If `url` parameter exists:
1. When transport is specified as `streamable_http`, use Streamable HTTP connection.
@@ -480,47 +421,10 @@ class MCPClient:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
"""
# Store config for reconnection
self._mcp_server_config = mcp_server_config
self._server_name = name
ready: asyncio.Future = asyncio.get_running_loop().create_future()
# Defensively cancel any existing connection task that was not cleaned
# up before this call (e.g. if connect_to_server is called twice).
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
self._connection_task = asyncio.create_task(
self._run_connection(mcp_server_config, name, ready),
name=f"mcp-conn:{name}",
)
try:
await ready
except asyncio.CancelledError:
# Caller was cancelled while waiting — tear down the connection task.
# cancel() is asynchronous; the task will not finish until the next
# event-loop iteration, so we track it in _old_connection_tasks so
# that cleanup() can await it later.
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
raise
except Exception:
# _do_connect raised; the connection task's finally block may still
# be running (e.g. awaiting stack.aclose()). Track it so that
# cleanup() can await it, but do NOT cancel it — we want the
# finally block to finish cleaning up resources naturally.
if self._connection_task and not self._connection_task.done():
self._old_connection_tasks.append(self._connection_task)
self._connection_task = None
raise
async def _do_connect(self, mcp_server_config: dict, name: str) -> None:
"""Internal: perform the actual connection inside _run_connection's task."""
# exit_stack is always set by _run_connection before _do_connect is called.
assert self.exit_stack is not None
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(
@@ -659,32 +563,16 @@ class MCPClient:
self.tools = response.tools
return response
def _cancel_connection_task(self, task: asyncio.Task) -> None:
"""Cancel a connection owner task and track it until it finishes."""
# Prune already-finished tasks to avoid accumulating references over
# many reconnections in a long-running process.
self._old_connection_tasks = [
t for t in self._old_connection_tasks if not t.done()
]
if task.done():
return
task.cancel()
self._old_connection_tasks.append(task)
async def _reconnect(self) -> None:
"""Reconnect to the MCP server using the stored configuration.
Cancels the current _connection_task (which owns the exit_stack and all
anyio cancel scopes) and starts a fresh one. Because each connection
task enters and exits its own anyio cancel scope, there is no
cross-task cancel-scope violation and no GC finalizer surprise.
Uses asyncio.Lock to ensure thread-safe reconnection in concurrent environments.
Raises:
Exception: raised when reconnection fails
"""
async with self._reconnect_lock:
# Check if already reconnecting (useful for logging)
if self._reconnecting:
logger.debug(
f"MCP Client {self._server_name} is already reconnecting, skipping"
@@ -700,16 +588,17 @@ class MCPClient:
f"Attempting to reconnect to MCP server {self._server_name}..."
)
# Cancel the old connection task. Its finally block will call
# exit_stack.aclose() from within the correct task context, so
# anyio cancel scopes are exited cleanly without triggering the
# GC-finalizer busy-spin bug.
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
if self.exit_stack:
self._old_exit_stacks.append(self.exit_stack)
# Mark old session as invalid
self.session = None
# Reconnect — this creates a new _connection_task.
# Create new exit stack for new connection
self.exit_stack = AsyncExitStack()
# Reconnect using stored config
await self.connect_to_server(self._mcp_server_config, self._server_name)
await self.list_tools_and_save()
@@ -774,20 +663,19 @@ class MCPClient:
return await _call_with_retry()
async def cleanup(self) -> None:
"""Clean up resources by cancelling the connection owner task."""
# Cancel current and any old connection tasks via the shared helper so
# all cancellation + tracking behaviour goes through one code path.
if self._connection_task:
self._cancel_connection_task(self._connection_task)
self._connection_task = None
"""Clean up resources including old exit stacks from reconnections"""
# Close current exit stack
try:
await self.exit_stack.aclose()
except Exception as e:
logger.debug(f"Error closing current exit stack: {e}")
if self._old_connection_tasks:
pending = [t for t in self._old_connection_tasks if not t.done()]
if pending:
await asyncio.gather(*pending, return_exceptions=True)
self._old_connection_tasks.clear()
# Don't close old exit stacks as they may be in different task contexts
# They will be garbage collected naturally
# Just clear the list to release references
self._old_exit_stacks.clear()
# Set running_event to unblock any waiting tasks
# Set running_event first to unblock any waiting tasks
self.running_event.set()

View File

@@ -502,7 +502,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err)
if payload.get("msg_id"):
fallback_payload = payload.copy()
fallback_payload.pop("msg_id", None)
try:
ret = await send_func(fallback_payload)
logger.info("[QQOfficial] 使用主动发送接口发送成功。")

View File

@@ -723,21 +723,8 @@ class FunctionToolManager:
logger.debug(f"MCP client {name} task was cancelled")
raise
finally:
# Cleanup in the same task that entered the anyio contexts:
# asyncio.shield() would schedule the coroutine as a separate
# Task, and anyio cancel scopes cannot exit across tasks (#9068).
# Absorb late cancellations so a forced shutdown cannot abort
# the cleanup halfway.
task = asyncio.current_task()
while True:
try:
await self._terminate_mcp_client(name)
break
except asyncio.CancelledError:
# Task.uncancel() is 3.11+; on 3.10 absorbing the
# cancellation is sufficient.
if task is not None and hasattr(task, "uncancel"):
task.uncancel()
# Cleanup in the same task that entered the anyio contexts
await asyncio.shield(self._terminate_mcp_client(name))
lifecycle_task = asyncio.create_task(
connect_and_lifecycle(), name=f"mcp-client:{name}"

View File

@@ -1,41 +0,0 @@
## [4.26.5] - 2026-07-07
### Added
- Added support for setting a custom bot ID while creating a platform by scanning a QR code. (#9067)
- Added ChatUI project workspaces. (#9066)
- Added improved ChatUI attachment display. (#9134)
- Added malformed tool-call name sanitation in `ToolLoopAgentRunner`. (#9144)
- Added model metadata in selectors and a chat token usage indicator. (#9161)
- Added event-loop diagnostics and expanded diagnostics documentation. (#9168)
- Added documentation for cross-platform compatibility and Python version support.
- Added test coverage for POSIX file URI root preservation. (#8906)
### Changed
- Log event-loop watchdog startup at the info level.
- Keep model metadata separate from providers. (#9161)
- Polish snackbar styling in the WebUI. (#9173)
### Fixed
- Enabled only synced ModelScope MCP servers. (#9084)
- Improved MCP client startup and shutdown handling to avoid idle spinning and clean up on errors, timeouts, and cancellation. (#9070)
- Replaced fragile knowledge-base duplicate-name error matching with a pre-check. (#9121)
- Improved knowledge-base retrieval resilience on per-knowledge-base failures and rerank selection. (#9122)
- Cleaned up `KBMedia` records on document deletion and fixed knowledge-base stats updates. (#9120)
- Aligned the knowledge-base CRUD API contract with `kb_name`, canonical payload fields, and OpenAPI types. (#9000)
- Constrained custom workspace paths and preserved absolute custom workspace paths.
- Improved web search API key failover.
- Enforced ownership when reading ChatUI sessions. (#9141)
- Secured project update temporary staging. (#9083)
- Improved QQ Official retry behavior when websocket send returns `None`. (#8977)
- Preserved QQ Official quoted message context. (#9164)
- Removed QQ Official reply IDs on proactive fallback so oversized passive replies can succeed via proactive sending. (#9169)
- Adapted MiMo STT to V2.5 models and rejected non-WAV audio payloads. (#9118)
- Serialized webchat history timestamps as UTC-aware ISO strings. (#9159)
- Synced plugin activation status on enable in the WebUI. (#9156)
- Exited MCP `anyio` contexts in the lifecycle task on disable. (#9132)
- Fixed leaked SSE exit stack handling that could cause MCP epoll busy-wait. (#8307)
[4.26.5]: https://github.com/AstrBotDevs/AstrBot/compare/v4.26.4...v4.26.5

View File

@@ -7,12 +7,9 @@
<v-snackbar v-if="toastStore.current" v-model="snackbarShow" :color="toastStore.current.color"
:timeout="toastStore.current.timeout" :multi-line="toastStore.current.multiLine"
:location="toastStore.current.location" close-on-back>
<div class="app-snackbar-message">
<v-icon v-if="toastIcon" :icon="toastIcon" size="24" />
<span>{{ toastStore.current.message }}</span>
</div>
{{ toastStore.current.message }}
<template #actions v-if="toastStore.current.closable">
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Close notification" @click="snackbarShow = false" />
<v-btn variant="text" @click="snackbarShow = false">关闭</v-btn>
</template>
</v-snackbar>
</template>
@@ -35,14 +32,6 @@ const snackbarShow = computed({
}
})
const toastIcon = computed(() => ({
success: 'mdi-check-circle-outline',
error: 'mdi-alert-circle-outline',
warning: 'mdi-alert-outline',
info: 'mdi-information-outline',
primary: 'mdi-information-outline'
})[toastStore.current?.color] || '')
onMounted(() => {
const desktopBridge = window.astrbotDesktop
if (!desktopBridge?.onTrayRestartBackend) {

View File

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

View File

@@ -2,11 +2,7 @@
<div
v-if="props.active"
class="chat-ui"
:class="{
'is-dark': isDark,
'sidebar-collapsed': isSidebarCollapsed,
'dashboard-layout': !props.chatboxMode,
}"
:class="{ 'is-dark': isDark, 'sidebar-collapsed': isSidebarCollapsed }"
>
<v-navigation-drawer
v-model="chatSidebarDrawer"
@@ -173,6 +169,160 @@
</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
@@ -401,12 +551,20 @@ 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";
@@ -434,7 +592,12 @@ import { useProjects } from "@/composables/useProjects";
import { useChatHeaderStore } from "@/stores/chatHeader";
import { useCustomizerStore } from "@/stores/customizer";
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import {
useI18n,
useLanguageSwitcher,
useModuleI18n,
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import {
contextLimit,
@@ -458,6 +621,8 @@ const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const toast = useToast();
const { languageOptions, currentLanguage, switchLanguage, locale } =
useLanguageSwitcher();
const {
sessions,
currSessionId,
@@ -624,6 +789,20 @@ 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(
@@ -1478,6 +1657,9 @@ async function stopCurrentSession() {
}
}
function toggleTheme() {
customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark");
}
</script>
<style scoped>
@@ -1519,10 +1701,6 @@ async function stopCurrentSession() {
--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;
@@ -1675,7 +1853,8 @@ async function stopCurrentSession() {
flex: 0 0 auto;
}
.new-chat-btn {
.new-chat-btn,
.settings-btn {
color: rgb(var(--v-theme-on-surface));
border-radius: 8px;
}
@@ -1690,7 +1869,8 @@ async function stopCurrentSession() {
margin-right: 12px !important;
}
.new-chat-btn {
.new-chat-btn,
.settings-btn {
width: 100%;
min-height: 36px;
height: 36px;
@@ -1706,11 +1886,13 @@ async function stopCurrentSession() {
margin-bottom: 2px;
}
.new-chat-btn:not(.icon-only) {
.new-chat-btn:not(.icon-only),
.settings-btn:not(.icon-only) {
padding-inline: 10px;
}
.new-chat-btn.icon-only {
.new-chat-btn.icon-only,
.settings-btn.icon-only {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
@@ -1719,19 +1901,27 @@ async function stopCurrentSession() {
justify-content: center;
}
.chat-sidebar.collapsed .new-chat-btn.icon-only :deep(.v-btn__content) {
.chat-sidebar.collapsed .new-chat-btn.icon-only :deep(.v-btn__content),
.chat-sidebar.collapsed .settings-btn.icon-only :deep(.v-btn__content) {
display: flex;
align-items: center;
justify-content: center;
}
.new-chat-btn :deep(.v-btn__content) {
.new-chat-btn :deep(.v-btn__content),
.settings-btn :deep(.v-btn__content) {
min-width: 0;
font-size: 14px;
line-height: 20px;
}
.new-chat-btn:hover {
.chat-sidebar.collapsed .sidebar-footer {
display: flex;
justify-content: center;
}
.new-chat-btn:hover,
.settings-btn:hover {
background: var(--chat-session-active-bg);
}
@@ -1848,6 +2038,72 @@ async function stopCurrentSession() {
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;
@@ -2051,9 +2307,4 @@ 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 end" : "top",
props.variant === "header" ? "bottom start" : "top",
);
const selectedProvider = computed(() =>

View File

@@ -292,7 +292,7 @@
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack" location="top">
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" location="top">
{{ save_message }}
</v-snackbar>
</div>

View File

@@ -746,7 +746,7 @@
v-model="snackbar.show"
:timeout="3500"
:color="snackbar.color"
elevation="6"
elevation="24"
>
{{ snackbar.message }}
</v-snackbar>

View File

@@ -314,7 +314,7 @@ watch(viewMode, async (mode) => {
/>
<!-- Snackbar -->
<v-snackbar :timeout="2000" elevation="6" :color="snackbar.color" v-model="snackbar.show">
<v-snackbar :timeout="2000" elevation="24" :color="snackbar.color" v-model="snackbar.show">
{{ snackbar.message }}
</v-snackbar>
</template>

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { RouterView, useRoute } from "vue-router";
import { ref, onMounted, computed } from "vue";
import { ref, onMounted, computed, watch } 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";
@@ -17,13 +18,27 @@ 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(() => isPluginPageRoute.value);
const isFullScreenRoute = computed(
() => isCurrentChatRoute.value || isPluginPageRoute.value,
);
const shouldMountChat = ref(isCurrentChatRoute.value);
const showSidebar = computed(() => !isCurrentChatRoute.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;
@@ -92,11 +107,18 @@ onMounted(() => {
style="z-index: 9999; position: absolute; opacity: 0.3"
/>
<VerticalHeaderVue />
<VerticalSidebarVue />
<v-main>
<VerticalSidebarVue v-if="showSidebar" />
<v-main
:class="{ 'chat-main': isCurrentChatRoute }"
:style="{
height: isCurrentChatRoute ? '100vh' : undefined,
overflow: isCurrentChatRoute ? 'hidden' : undefined,
}"
>
<v-container
fluid
class="page-wrapper"
:class="{ 'chat-mode-container': isCurrentChatRoute }"
:style="{
height: isFullScreenRoute ? '100%' : 'calc(100% - 8px)',
padding: isFullScreenRoute ? '0' : undefined,
@@ -107,10 +129,18 @@ onMounted(() => {
:style="{
height: '100%',
width: '100%',
overflow: isCurrentChatRoute ? 'hidden' : undefined,
position: isPluginPageRoute ? 'relative' : undefined,
}"
>
<RouterView />
<div
v-if="shouldMountChat"
v-show="isCurrentChatRoute"
style="height: 100%; width: 100%; overflow: hidden"
>
<Chat :active="isCurrentChatRoute" />
</div>
<RouterView v-if="!isCurrentChatRoute" />
</div>
</v-container>
</v-main>
@@ -124,3 +154,14 @@ 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 { useTheme } from "vuetify";
import { useDisplay, useTheme } from "vuetify";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import { useLanguageSwitcher } from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
@@ -29,8 +29,11 @@ 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);
@@ -121,6 +124,21 @@ 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();
@@ -873,7 +891,11 @@ function openReleaseNotesDialog(body: string, tag: string) {
}
function handleLogoClick() {
router.push("/about");
if (isChatPath.value) {
aboutDialog.value = true;
} else {
router.push("/about");
}
}
getVersion();
@@ -889,12 +911,107 @@ 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(() => {
@@ -934,21 +1051,29 @@ 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 按钮 -->
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn
v-if="!isChatPath"
style="margin-left: 16px"
class="hidden-md-and-down"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 menu 按钮 -->
<v-btn
v-if="!isChatPath"
class="hidden-lg-and-up ms-3"
icon
rounded="sm"
@@ -959,6 +1084,7 @@ onMounted(async () => {
</v-btn>
<div
v-if="!isChatPath"
class="logo-container"
:class="{
'mobile-logo': $vuetify.display.xs,
@@ -992,9 +1118,6 @@ onMounted(async () => {
</v-icon>
</v-btn>
<v-spacer />
<!-- 版本提示信息 - 在手机上隐藏 -->
<div
v-if="isChatPath"
class="chat-header-context"
@@ -1005,7 +1128,10 @@ onMounted(async () => {
</div>
</div>
<div v-else class="mr-4 hidden-xs">
<v-spacer />
<!-- 版本提示信息 - 在手机上隐藏 -->
<div v-if="!isChatPath" class="mr-4 hidden-xs">
<small v-if="hasNewVersion">
{{ t("core.header.version.hasNewVersion") }}
</small>
@@ -1014,16 +1140,33 @@ onMounted(async () => {
</small>
</div>
<div class="header-actions">
<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>
<!-- 功能菜单 -->
<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 mr-4"
color="var(--v-theme-surface)"
variant="flat"
:class="[
'action-btn',
isChatPath ? 'chat-action-btn' : 'mr-4',
]"
:color="isChatPath ? undefined : 'var(--v-theme-surface)'"
:variant="isChatPath ? 'text' : 'flat'"
rounded="sm"
icon
>
@@ -1031,6 +1174,29 @@ 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
@@ -1867,6 +2033,21 @@ 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;
@@ -1890,15 +2071,13 @@ onMounted(async () => {
.chat-header-context {
min-width: 0;
max-width: min(360px, 38vw);
max-width: clamp(180px, calc(100vw - 600px), 460px);
align-self: stretch;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
padding: 5px 10px 5px 0;
padding: 5px 18px 5px 0;
overflow: hidden;
text-align: right;
}
.chat-header-context .provider-trigger--header {
@@ -1911,7 +2090,7 @@ onMounted(async () => {
.chat-header-context .provider-trigger-title {
min-width: 0;
max-width: min(240px, 30vw);
max-width: min(300px, 52vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1938,6 +2117,41 @@ 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;
@@ -2026,6 +2240,30 @@ 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;
@@ -2234,8 +2472,17 @@ 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 - 210px);
max-width: calc(100vw - 92px);
padding-right: 8px;
}

View File

@@ -1,15 +1,17 @@
<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, rail: Boolean });
const props = defineProps({ item: Object, level: Number });
const { t } = useI18n();
const customizer = useCustomizerStore();
const route = useRoute();
const itemStyle = computed(() => {
const lvl = props.level ?? 0;
const indent = props.rail ? '0px' : `${lvl * 24}px`;
const indent = customizer.mini_sidebar ? '0px' : `${lvl * 24}px`;
return { '--indent-padding': indent };
});
@@ -20,11 +22,7 @@ const isItemActive = computed(() => {
const [path, hash] = props.item.to.split('#');
return route.path === path && route.hash === `#${hash}`;
}
const targetPath = props.item.to.replace(/\/$/, '') || '/';
if (targetPath === '/') {
return route.path === targetPath;
}
return route.path === targetPath || route.path.startsWith(`${targetPath}/`);
return route.path === props.item.to;
});
const itemTitle = computed(() => {
@@ -35,19 +33,9 @@ const itemTitle = computed(() => {
</script>
<template>
<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"
<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"
:style="{ '--indent-padding': '0px' }">
<v-list-item-title style="font-size: 14px; font-weight: 500; line-height: 1.2; word-break: break-word;">
{{ itemTitle }}
@@ -57,33 +45,10 @@ 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" :rail="rail" />
<NavItem :item="child" :level="(level || 0) + 1" />
</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">
@@ -104,16 +69,10 @@ const itemTitle = computed(() => {
</template>
<style>
.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;
/* 在折叠(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);
}
</style>

View File

@@ -1,15 +1,18 @@
<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 } = useI18n();
const { t, locale } = useI18n();
const customizer = useCustomizerStore();
const theme = useTheme();
const { pluginItems } = usePluginSidebarItems();
function buildSidebarMenu() {
@@ -102,23 +105,159 @@ 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';
@@ -152,6 +291,30 @@ 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>
@@ -163,27 +326,46 @@ function startSidebarResize(event) {
app
class="leftSidebar"
:width="sidebarWidth"
:rail="isRailSidebar"
:rail="customizer.mini_sidebar"
>
<div class="sidebar-container">
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': isRailSidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': customizer.mini_sidebar }]" 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" :rail="isRailSidebar" />
<NavItem :item="item" class="leftPadding" />
</template>
</v-list>
<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" />
<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>
</v-btn>
</div>
</div>
<div
v-if="!isRailSidebar && !isMobile && customizer.Sidebar_drawer"
v-if="!customizer.mini_sidebar && customizer.Sidebar_drawer"
class="sidebar-resize-handle"
@mousedown="startSidebarResize"
:class="{ 'resizing': isResizing }"
@@ -191,6 +373,44 @@ function startSidebarResize(event) {
</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,11 +26,6 @@ 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',
@@ -49,12 +44,17 @@ const sidebarItem: menu[] = [
{
title: 'core.navigation.extension',
icon: 'mdi-puzzle',
to: '/extension',
to: '/extension#installed',
children: [
{
title: 'core.navigation.extensionTabs.installed',
icon: 'mdi-puzzle',
to: '/extension'
to: '/extension#installed'
},
{
title: 'core.navigation.extensionTabs.market',
icon: 'mdi-store',
to: '/extension#market'
},
{
title: 'core.navigation.extensionTabs.mcp',

View File

@@ -21,10 +21,6 @@ export default createVuetify({
VCard: {
rounded: 'lg'
},
VSnackbar: {
elevation: 6,
rounded: 'lg'
},
VTextField: {
rounded: 'lg'
},

View File

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

View File

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

View File

@@ -20,42 +20,6 @@ html {
.v-overlay.v-snackbar {
--v-layout-left: 0px !important;
--v-layout-right: 0px !important;
.v-snackbar__wrapper {
min-height: 52px;
border-radius: 8px !important;
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.16) !important;
}
.v-snackbar__content {
padding: 14px 18px;
line-height: 1.35;
font-weight: 600;
}
.v-snackbar__actions {
margin-inline-end: 6px;
}
.v-snackbar__actions .v-btn {
color: inherit;
}
.v-snackbar__wrapper.bg-success {
color: #ffffff !important;
background-color: #457c3c !important;
}
}
.app-snackbar-message {
display: flex;
align-items: center;
gap: 14px;
.v-icon {
flex: 0 0 auto;
opacity: 0.95;
}
}
.customizer-btn .icon {

View File

@@ -65,101 +65,6 @@
.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 {
@@ -217,16 +122,3 @@
}
}
}
.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,8 +10,6 @@ import Chat from '@/components/chat/Chat.vue'
<style scoped>
.chat-container {
height: 100%;
min-height: calc(100vh - 66px);
overflow: hidden;
height: calc(100vh - 60px)
}
</style>
</style>

View File

@@ -166,7 +166,7 @@
</v-card>
</v-dialog>
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack">
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
</v-snackbar>

View File

@@ -377,7 +377,7 @@
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="6" :color="messageType" v-model="showMessage" location="top">
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
{{ message }}
</v-snackbar>
</div>

View File

@@ -390,11 +390,7 @@ const updateDialogPluginLogo = computed(() => {
</v-card>
</v-col>
<v-col
v-if="activeTab === 'installed' || activeTab === 'market'"
cols="12"
md="12"
>
<v-col v-if="activeTab === 'market'" cols="12" md="12">
<div class="d-flex align-center justify-center mt-4 mb-4 gap-4">
<v-btn
variant="text"
@@ -514,7 +510,7 @@ const updateDialogPluginLogo = computed(() => {
<v-snackbar
:timeout="2000"
elevation="6"
elevation="24"
:color="snack_success"
v-model="snack_show"
location="bottom center"

View File

@@ -220,7 +220,7 @@
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="6" :color="save_message_success" v-model="save_message_snack"
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>

View File

@@ -650,7 +650,7 @@
</v-dialog>
<!-- 提示信息 -->
<v-snackbar v-model="snackbar" :timeout="3000" elevation="6" :color="snackbarColor" location="top">
<v-snackbar v-model="snackbar" :timeout="3000" elevation="24" :color="snackbarColor" location="top">
{{ snackbarText }}
</v-snackbar>

View File

@@ -11,10 +11,7 @@
:key="item.id"
type="button"
class="settings-nav__item"
:class="{
'settings-nav__item--active': activeSettingsSection === item.id,
'settings-nav__item--divider': item.dividerBefore
}"
:class="{ 'settings-nav__item--active': activeSettingsSection === item.id }"
:aria-pressed="activeSettingsSection === item.id"
@click="activeSettingsSection = item.id"
>
@@ -150,33 +147,6 @@
<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"
@@ -425,39 +395,12 @@
</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"
@@ -478,10 +421,9 @@ 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 { useI18n, useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { PurpleTheme } from '@/theme/LightTheme';
import { useToastStore } from '@/stores/toast';
@@ -489,7 +431,6 @@ 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();
@@ -501,11 +442,6 @@ 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;
@@ -540,11 +476,6 @@ 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([]);
@@ -566,7 +497,6 @@ 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 },
@@ -590,61 +520,13 @@ 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: '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')
}
{ id: 'openapi', label: tm('sections.openapi.title'), icon: 'mdi mdi-api' }
]);
const configIncludedScopes = ['bot', 'provider'];
@@ -1034,8 +916,6 @@ onMounted(async () => {
activeSettingsSection.value = 'maintenance';
} else if (hash.includes('settings-openapi')) {
activeSettingsSection.value = 'openapi';
} else if (hash.includes('settings-resources')) {
activeSettingsSection.value = 'resources';
}
});
@@ -1115,20 +995,6 @@ 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));
@@ -1266,23 +1132,6 @@ 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' || activeTab === 'market'">
<v-tab-item v-show="activeTab === 'installed'">
<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 === 'installed' || activeTab === 'market'">
<v-tab-item v-show="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

@@ -257,7 +257,7 @@
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="6" :color="messageType" v-model="showMessage" location="top">
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
{{ message }}
</v-snackbar>
</div>

View File

@@ -231,7 +231,6 @@ export default defineConfig({
base: "/others",
collapsed: true,
items: [
{ text: "异常诊断", link: "/diagnostics" },
{ text: "自部署文转图", link: "/self-host-t2i" },
{ text: "插件下载不了?试试自建 GitHub 加速服务", link: "/github-proxy" },
],
@@ -485,7 +484,6 @@ export default defineConfig({
base: "/en/others",
collapsed: true,
items: [
{ text: "Diagnostics", link: "/diagnostics" },
{ text: "Self-hosted HTML to Image", link: "/self-host-t2i" },
],
},

View File

@@ -1,83 +0,0 @@
# Diagnostics
This page provides a general checklist for diagnosing AstrBot issues. When something goes wrong, first identify which stage is affected, then collect the relevant logs. This makes issue reports easier to reproduce and investigate.
## Common Issue Types
- Startup failures: the process exits after startup, WebUI is unavailable, or database/config loading fails.
- Platform connection issues: QQ, OneBot, Telegram, WeCom, or other platforms cannot connect, receive messages, or send messages.
- Model request issues: replies take too long, requests time out, 429/5xx errors appear, or the proxy/API base is unavailable.
- Plugin or MCP issues: enabling fails, tool calls fail, dependencies fail to install, or an MCP service does not respond.
- Slow tasks or abnormal CPU usage: messages, proactive tasks, or scheduled tasks start but continue much later; or the process keeps unusually high CPU usage.
## Logs to Check First
Start with the main AstrBot log:
```text
data/logs/astrbot.log
```
For Docker deployments, also check container logs:
```bash
docker logs <container-name>
```
If the issue involves slow tasks, abnormal CPU usage, or multiple sessions becoming slow at the same time, also check the event loop diagnostic logs:
```text
data/logs/event_loop_watchdog.log
data/logs/event_loop_watchdog.log.1
```
`event_loop_watchdog.log` rotates to `.1` after it exceeds 1 MB.
## General Checklist
1. Identify the time of the incident, then collect logs from 1 to 3 minutes before and after it.
2. Check the scope: all platforms, one platform, one group, one user, or one plugin.
3. For slow or missing model replies, check the model provider status, API key, API base, proxy, network, request timeout, and retry logs.
4. For plugin or MCP issues, disable recently installed or updated plugins first, then check plugin dependencies and MCP service logs.
5. For platform messaging issues, check whether the adapter is connected, platform-side settings are correct, and callback/WebSocket URLs are reachable.
6. For slow tasks or abnormal CPU usage, see "Event Loop Lag Diagnostics" below.
## Event Loop Lag Diagnostics
The event loop schedules messages, plugins, scheduled jobs, model requests, and tool calls. If it is blocked by synchronous code, many features can look delayed at once.
Common symptoms:
- Logs stop after `ready to request llm provider`, `acquired session lock for llm request`, or a tool result, then continue much later.
- A proactive or scheduled task starts, but one step in the middle takes a long time to continue.
- Multiple platforms or sessions become slow at the same time.
- CPU usage stays at 100%, or CPU usage is low but requests keep waiting for an external service.
If the main log contains the following entry, the event loop experienced visible scheduling delay:
```text
Event loop lag detected: 18.432s (threshold 15.000s).
```
If the event loop does not resume for a long time, AstrBot writes Python thread stacks to:
```text
data/logs/event_loop_watchdog.log
```
When reading this file, focus on the top frames. Useful clues often include plugin functions, platform adapters, MCP tools, synchronous network requests, `time.sleep()`, `subprocess.run()`, or CPU-heavy loops.
## What to Include in an Issue
When filing an issue, include as much of the following as possible:
- Approximate time of the incident and timezone.
- AstrBot version, deployment method (Docker, manual deployment, desktop client, etc.), and operating system.
- Trigger path: startup, normal chat, group chat, platform callback, scheduled task, MCP tool, plugin feature, etc.
- Scope: all sessions, one platform, one group, one user, or one plugin.
- Logs from `data/logs/astrbot.log` for 1 to 3 minutes around the incident.
- If the issue involves lag or abnormal CPU usage, include `data/logs/event_loop_watchdog.log` and `data/logs/event_loop_watchdog.log.1`.
- For Docker deployments, include the matching `docker logs` output.
- Installed plugin list, and whether the issue still happens after disabling third-party plugins.
Before sharing logs, redact API keys, tokens, cookies, private chat content, and other sensitive information.

View File

@@ -1,83 +0,0 @@
# 异常诊断
本文用于整理 AstrBot 出现异常时的通用排查方法。遇到问题时,先确定问题发生在哪个阶段,再收集对应日志;这样提交 Issue 时更容易复现和定位。
## 常见问题类型
- 启动失败进程启动后退出、WebUI 打不开、数据库或配置加载失败。
- 平台连接异常QQ、OneBot、Telegram、企业微信等平台无法连接、收不到消息或无法发送消息。
- 模型请求异常:长时间无回复、频繁超时、报 429/5xx、代理或 API Base 不可用。
- 插件或 MCP 异常启用失败、工具调用失败、依赖安装失败、MCP 服务无响应。
- 任务卡顿或 CPU 异常:消息、主动任务、定时任务已经触发,但中间步骤很久才继续;或进程 CPU 长时间异常升高。
## 先看哪些日志
优先查看 AstrBot 主日志:
```text
data/logs/astrbot.log
```
如果使用 Docker 部署,也可以查看容器日志:
```bash
docker logs <container-name>
```
如果问题和任务卡顿、CPU 异常、多个会话同时变慢有关,还要查看事件循环诊断日志:
```text
data/logs/event_loop_watchdog.log
data/logs/event_loop_watchdog.log.1
```
`event_loop_watchdog.log` 超过 1MB 后会轮转为 `.1`
## 通用排查步骤
1. 确认问题发生的时间点,并截取该时间前后 1 到 3 分钟的日志。
2. 确认问题范围:是所有平台都异常,还是只有某个平台、某个群、某个用户、某个插件异常。
3. 如果是模型无回复或很慢检查模型服务商状态、API Key、API Base、代理、网络、请求超时和重试日志。
4. 如果是插件或 MCP 问题,先禁用最近安装或更新的插件,观察问题是否消失;同时检查插件依赖和 MCP 服务日志。
5. 如果是平台收发消息异常,检查平台适配器是否已连接、平台后台配置是否正确、回调地址或 WebSocket 地址是否可访问。
6. 如果是卡顿或 CPU 异常,参考下方“事件循环卡顿诊断”。
## 事件循环卡顿诊断
事件循环负责调度消息、插件、定时任务、模型请求和工具调用。如果它被同步代码阻塞,很多功能都会表现为延迟。
常见现象:
- 日志停在 `ready to request llm provider``acquired session lock for llm request`、工具调用结果之后,很久才继续。
- 主动任务或定时任务已经触发,但中间某一步迟迟不继续。
- 多个平台或多个会话同时变慢。
- CPU 长时间 100%,或 CPU 不高但请求一直等待外部服务返回。
如果主日志出现以下内容,说明事件循环经历了明显延迟:
```text
Event loop lag detected: 18.432s (threshold 15.000s).
```
如果事件循环长时间没有恢复AstrBot 会把 Python 线程栈写入:
```text
data/logs/event_loop_watchdog.log
```
查看该文件时重点关注栈顶附近正在执行的代码。常见线索包括插件函数、平台适配器、MCP 工具、同步网络请求、`time.sleep()``subprocess.run()`、CPU 密集循环等。
## 提交 Issue 时请附带
提交问题时,请尽量提供以下信息:
- 问题发生的大致时间点和时区。
- AstrBot 版本、部署方式Docker、手动部署、桌面客户端等、操作系统。
- 触发方式启动、普通聊天、群聊、平台回调、定时任务、MCP 工具、插件功能等。
- 影响范围:所有会话、某个平台、某个群、某个用户,还是某个插件。
- `data/logs/astrbot.log` 中问题发生前后 1 到 3 分钟的日志。
- 如果存在卡顿或 CPU 异常,请附带 `data/logs/event_loop_watchdog.log``data/logs/event_loop_watchdog.log.1`
- 如果使用 Docker请附带对应时间段的 `docker logs`
- 已安装插件列表,以及问题是否在禁用第三方插件后仍然出现。
提交日志前请先检查并遮盖 API Key、Token、Cookie、私聊内容等敏感信息。

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.26.5"
version = "4.26.4"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }

View File

@@ -1,4 +1,3 @@
import asyncio
import json
import pytest
@@ -349,65 +348,6 @@ def test_firecrawl_tools_are_registered_as_builtin_tools():
assert manager.is_builtin_tool("firecrawl_extract_web_page") is True
@pytest.mark.asyncio
async def test_mcp_shutdown_cleanup_runs_in_lifecycle_task(monkeypatch):
"""Disabling an MCP server must clean up in the task that connected.
anyio cancel scopes entered in connect_to_server() can only be exited
from the same task, otherwise the scope state is corrupted and its
cancellation loop spins at 100% CPU (#9068).
"""
manager = FunctionToolManager()
seen = {}
async def fake_connect(self, config, name):
seen["connect_task"] = asyncio.current_task()
async def fake_list_tools(self):
self.tools = []
async def fake_cleanup(self):
seen["cleanup_task"] = asyncio.current_task()
monkeypatch.setattr(ftm.MCPClient, "connect_to_server", fake_connect)
monkeypatch.setattr(ftm.MCPClient, "list_tools_and_save", fake_list_tools)
monkeypatch.setattr(ftm.MCPClient, "cleanup", fake_cleanup)
await manager.enable_mcp_server("dummy", {"command": "python"}, timeout=5)
await manager.disable_mcp_server("dummy", timeout=5)
assert seen["cleanup_task"] is seen["connect_task"]
assert "dummy" not in manager.mcp_client_dict
@pytest.mark.asyncio
async def test_mcp_shutdown_cleanup_survives_late_cancellation(monkeypatch):
"""A cancellation arriving mid-cleanup must not abort the cleanup."""
manager = FunctionToolManager()
cleanup_calls = []
async def fake_connect(self, config, name):
pass
async def fake_list_tools(self):
self.tools = []
async def fake_cleanup(self):
cleanup_calls.append(asyncio.current_task())
if len(cleanup_calls) == 1:
raise asyncio.CancelledError()
monkeypatch.setattr(ftm.MCPClient, "connect_to_server", fake_connect)
monkeypatch.setattr(ftm.MCPClient, "list_tools_and_save", fake_list_tools)
monkeypatch.setattr(ftm.MCPClient, "cleanup", fake_cleanup)
await manager.enable_mcp_server("dummy", {"command": "python"}, timeout=5)
await manager.disable_mcp_server("dummy", timeout=5)
assert len(cleanup_calls) == 2
assert "dummy" not in manager.mcp_client_dict
@pytest.mark.asyncio
async def test_modelscope_sync_enables_only_synced_servers(monkeypatch):
class FakeResponse: