* fix: preserve Lark rich text titles and whitespace
Preserve titles, paragraph breaks, indentation, and adjacent text in received and quoted Lark posts. Keep command matching compatible with leading whitespace and bot mentions, and add focused regression coverage.
* refactor: use event extras for Lark quote metadata
* fix(i18n): add missing dashboard translation keys
Several call sites reference keys that exist in no locale bundle, so the
i18n helper returns its truthy "[MISSING: key]" placeholder and the UI
leaks that instead of a translation.
- TemplateListEditor.vue used core.common.collapse/expand for the
expand/collapse tooltip; neither key existed. Add both to zh-CN,
en-US, ja-JP and ru-RU.
- BackupDialog.vue alerts core.common.unauthorized, which was absent
from every bundle. Add it to all four.
- PersonaSelector.vue reads core.shared.personaSelector.editPersona;
it existed in ru-RU only. Add it to zh-CN, en-US and ja-JP, matching
the wording of the existing createPersona entries.
- KBList.vue uses create.embeddingModelRequired as the validation rule
when creating a knowledge base. Sibling keys exist in all four
bundles but this one was missed.
The unauthorized values mirror the wording already used by the
equivalent messages.errors.auth.unauthorized entries.
* feat(i18n): add a dashboard i18n key check and fix key drift
The i18n helper returns "[MISSING: <key>]" (a truthy string) when a key is
absent, so `t(key) || 'fallback'` never falls back and the placeholder
leaks into the UI. A key missing from *every* locale is invisible to
locale-vs-locale comparison (src/i18n/validator.ts), because all bundles
agree on it, so those defects can only be caught by checking source usage
against the bundles.
Add dashboard/scripts/check-i18n-keys.mjs, run via `pnpm run i18n:check`
and wired into the Dashboard CI workflow. It assembles locale trees by
parsing each locale index.ts (bundle filenames are not runtime
namespaces: features/tool-use.json -> features.tooluse), strips comments
before scanning, respects the module-relative t()/tm() bindings from
useModuleI18n(), and skips plugin-provided strings. It has no dependency
on installed packages, so it runs without node_modules.
It reported two classes of problem, both fixed here:
- Used in source but absent from all four locales:
features.knowledge-base.detail.retrieval.debugMode and
.tsneVisualization (RetrievalTab.vue debug switch and t-SNE image alt).
- Locale drift against the zh-CN baseline:
features.subagent.form.personaPreview missing from en-US and ru-RU, and
the config-metadata use_markdown description/hint missing from ja-JP.
ru-RU additionally carries ~42 keys with no zh-CN counterpart (notably
agent_runner_type and the per-provider agent runner ids, which the backend
does use). Those are pre-existing and left alone; this check only fails on
keys that source code actually references.
* chore(i18n): remove orphaned ru-RU translation keys
Two groups of ru-RU keys reference paths the dashboard no longer reads,
left behind when zh-CN was migrated and ru-RU was not:
- features.tooluse.dialogs.syncProvider.* (22 leaves)
The syncProvider subtree now lives at the namespace root, which is what
McpServersSection.vue reads via useModuleI18n('features/tooluse'). PR #9932
relocated it for zh-CN but left ru-RU's copy under `dialogs`, so ru-RU
carried both.
- 20 features.config-metadata entries under provider_settings
Those paths were restructured into agent_runner.config.* by PR #9821 for
zh-CN. Only the entries with no zh-CN counterpart are removed here; most
provider_settings children are still live under their new paths and are
kept. platform_group.platform.kook_bot_nickname has no backend field at
all (no reference anywhere in astrbot/).
All four locales now expose an identical key set (4130 leaves each, no
missing and no extra keys), so this also removes the last locale drift the
i18n check could see.
* fix(i18n): make the key check enforce drift and scan injected translators
Addresses both blocking findings from the review.
1. Locale drift did not fail the check. The drift branch only printed its
results and never set the failure flag, and it compared in one direction
only (baseline keys absent elsewhere), so a locale could gain keys the
baseline lacks without any complaint. It now compares both directions and
fails on either, which is safe to enforce because the key sets are already
identical across all four locales (4130 leaves each).
2. Keys read through an injected translator were never scanned. The call
pattern only matched bare `t(` / `tm(`, so `props.tm('models.metadata.image')`
in ProviderModelsPanel.vue (8 call sites, reached via :tm="tm" from
ProviderPage / ProviderChatCompletionPanel) escaped validation entirely. A
key that resolves nowhere previously passed; it is now reported.
Injected usage carries no local module binding, so the parent's namespace is
unknown at the call site. Rather than guess it, such a key is only reported
when it resolves nowhere in any locale under any namespace, which keeps the
new pass free of false positives. The tradeoff is documented in the script:
a key that exists in some other namespace but not the injected one is not
caught.
Verified: deleting a baseline-only key from en-US now fails (was PASS), adding
an extra key to en-US now fails (was ignored), and the injected-translator probe
in ProviderModelsPanel.vue now fails with the call site (was PASS). The clean
tree still passes all three sections.
* fix(dashboard): keep the mobile shell and sidebar inside the visible viewport
The app shell and the sidebar were sized with 100vh, which on mobile browsers is the largest viewport (browser UI retracted). The drawer bottom - the last navigation entries plus the Settings/version footer - was painted underneath the browser UI, and the oversized flex box left no scrollable overflow, so the list could not be scrolled either.
Size the shell, the content card and the sidebar with dynamic viewport units (100dvh) instead. Desktop is unaffected because dvh equals vh there.
* fix(dashboard): size the chat sidebar with dynamic viewport units
The chat drawer kept 100vh, so on mobile its bottom - the session list tail and the Settings entry - was painted underneath the browser UI, exactly like the main sidebar. Its top: 0 is anchored to the same containing block (.v-application__wrap), so 100dvh is the matching fix.
* fix: deliver Slack TTS audio with text fallback
* fix: avoid duplicate Slack TTS fallback text
* fix: preserve Slack audio order and retry text delivery
* fix(dashboard): simplify local isolation controls and setup guidance
* docs: organize plugin and built-in AI guides
* docs: expand plugin guides for newcomers
* docs: refresh AI and extension guides with current UI screenshots
* docs: remove WebUI route details from usage guides
* fix(dashboard): update plugin market submit link and label
- Point the "submit plugin" button to https://cloud.astrbot.app instead of
the legacy AstrBot_Plugins_Collection repository link
- Change the button icon from mdi-github to mdi-upload
- Drop the "repository" wording from the submitRepo label:
zh-CN "提交我的插件", en-US "Submit My Extension",
ja-JP "プラグインを登録", ru-RU "Добавить мой плагин"
* fix(dashboard): edit `cloud.astrbot.app` site to `/publish`
Point the plugin submission contact links to the /publish page and rename the English entry from "Upload Plugin" to "Submit Plugin" so it matches the Chinese label.
When a background task finishes, the main agent is woken up and has to
call send_message_to_user to deliver the result. If it ends the turn
without doing so, the user never sees the result, and nothing was
logged because the existing warning only covers an empty response.
Log a warning with the tool name and task id when nothing reached the
user's session during the wake-up run.
Fixes#10395
- Add "Upload Plugin" and "提交插件" contact links to the new issue page
- Point both links to [AstrBot Cloud](https://cloud.astrbot.app) so users can submit plugins without opening a regular issue
* fix: reject unresolvable image references instead of faking a local path
A plugin can hand over a media reference such as
`/api/v1/files/tokens/<token>`. Neither an existing file nor a valid
payload, it was still returned as a local file, so `to_path()` resolved it
into a path that does not exist on disk (on Windows
`C:\api\v1\files\tokens\<token>`). That fabricated path reached
`req.image_urls` and the prompt, and the real failure only surfaced later
as a warning while preparing model images.
`_materialize_media_ref()` now enforces its own contract:
- A reference that exists but is not a regular file (directory, FIFO,
socket, device) raises ValueError for every media family.
- Image references must resolve to an existing file, URL or base64 payload.
Images are read right after resolution, so a missing source is a failure
rather than a file that may appear later.
- Audio, video and generic file references keep returning a local path that
does not exist yet: `ensure_wav()` and the STT retry loop rely on that for
platform races such as NapCat handing over a voice path before it is
readable.
Supporting changes:
- `to_base64_data(strict=False)` still degrades to `None` when an image ref
cannot be materialized, which payload assembly relies on to skip bad refs.
- `describe_media_ref()` reports unresolved refs as such instead of calling
them a local media path.
- Unavailable image warnings now include the original reference.
Refs #10264
* fix: keep pathlib tolerance for over-long and non-regular media refs
`_materialize_media_ref()` replaced `path.exists()` with a bare `path.stat()`
and caught only OSError. pathlib's `exists()` also swallows ValueError, which
Windows raises for paths past the 32767 character limit and for embedded NUL
bytes. A bare base64 payload longer than that limit failed with
`ValueError: stat: path too long for Windows` instead of being decoded, and
audio/video/file references lost the pending-path behaviour for the same input.
`to_base64_data()` had the matching gap: it caught only OSError around
resolution, so a directory or empty reference escaped as ValueError even in
non-strict mode. Payload assembly (openai/anthropic/gemini sources) relies on
`None` there to skip one bad image instead of failing the whole request, and
`is_recoverable_image_error()` already classifies ValueError as recoverable.
Both sites now catch what pathlib and the module's own recovery predicate treat
as a missing or unusable local file. Regression tests cover an oversized bare
base64 image, an oversized pending audio path, and directory/empty references
through the non-strict image path.
* docs: describe the media reference error contract precisely
Review feedback on the pull request:
- `_materialize_media_ref()` only raises FileNotFoundError for a missing file
URI when the reference is an image. Audio, video and file references return
the pending path instead, which the `Raises:` section now states instead of
reading as if it applied to every media family.
- `Image.convert_to_file_path()` can raise ValueError as well: for an existing
non-regular path, and already for an empty url/file before this branch.
Docstrings only, no behaviour change.
* fix: send Discord video components as attachments
* fix: stream Discord video attachments from disk
* fix: preserve Discord video filenames from URLs
* fix(provider): merge custom server tools with registered tools in Anthropic requests
* fix(provider): dedupe tool names and tolerate null custom body
Address review findings on the custom-server-tools merge:
- A custom tool sharing a name with a registered tool (or another
custom tool) produced duplicate definitions that the API rejects.
Merge by name instead; the custom entry wins, matching the override
authority extra_body already had for every other top-level key.
- An explicit null custom_extra_body crashed on .get("tools"). Null
or non-dict values now normalize to an empty body.
* fix(provider): avoid shadowed id in Anthropic streaming tool merge
---------
Co-authored-by: zouyonghe <62183434+zouyonghe@users.noreply.github.com>
* fix: fall back from a failed context summary without long retries
The summary request in LLMSummaryCompressor used the provider's default
retries, so an exhausted quota (HTTP 429) was retried five times with
backoff before compression fell back to truncation, holding up the reply
even after switching the chat model.
Request the summary once, since a failure already falls back to
truncation. When the context still exceeds the limit, drop whole rounds,
oldest first, and recount after each one until it fits, instead of
halving the messages once without checking again. The latest round is
always kept.
Fixes#10433
* fix: leave the token count out of the fallback warning
CodeQL flags values from count_tokens() as sensitive data, so logging the
count in the new warning raised a clear-text logging alert. The count is
already in the "Compress completed" line just before, so drop it here.
* fix: log how many rounds the context fallback dropped
* fix(provider): keep Anthropic tool calls whose streamed input is empty
* fix(provider): keep the input from the Anthropic tool_use start event
The tool_use buffer always started from an empty dict, so a provider
that puts the arguments in the content_block_start event instead of
streaming them lost them. Start from that input and let streamed JSON
replace it only when there is any.
The Agent Computer Use settings moved from 'Config -> Normal Config ->
AI -> Agent Computer Use' to 'Config -> AI -> Capabilities -> Agent
Computer Use'. Update the three permission-denied messages returned to
the model/user so the remediation path matches the current WebUI
navigation, consistent with the existing docs.
On platforms without a local process sandbox (e.g. Windows), the local
permission matrix collapses into a single access-mode dropdown. The
'File access only' and 'Unrestricted access' options previously shared
the same host-scope subtitle, making them look identical. Give each
mode its own hint that spells out whether Shell/Python execution and
network access are allowed.
* feat: ship astrbot CLI in Docker image for in-container password reset
Install the project itself into the image so the `astrbot` console
script (including `astrbot password` and `astrbot conf set`) is
available inside the container, and mark /AstrBot as an initialized
AstrBot root via the .astrbot marker file.
This lets Docker users reset a forgotten dashboard password with:
docker exec -it astrbot astrbot password
Document the workflow in the zh/en Docker deployment guides, including
the required container restart for the new password to take effect.
* docs: reorganize FAQ password reset section by deployment method
Split the forgot-password FAQ entry into Docker, uv/pip package, and
other deployment subsections. The Docker entry uses the in-container
CLI shipped with the image; the uv/pip entry covers `astrbot password`
and `astrbot run --reset-password`; the manual cmd_config.json edit
remains as the universal fallback.
* docs: mention --username option in uv/pip FAQ password reset entry
* docs: drop --reset-password mention from FAQ to keep reset steps unambiguous
* docs: clarify that the first astrbot in docker exec is the container name
* feat: use editable install for astrbot CLI in Docker image
Install the project with 'uv pip install -e .' so the astrbot console
script always reflects the source tree baked into the image, avoiding
drift between the CLI and the running server code.
* refactor(dashboard): complete the plugin pages to views rename
Finish the remaining Pages -> Views rename on the dashboard:
- Fix the plugin card "Open View" button crash by pointing
openPluginWebui at the renamed PluginView route
- Rename PluginPagePage.vue to PluginViewPage.vue and update its
internals (loadPluginView, pluginViewTitle, .plugin-view-* classes)
- Rename openPluginWebui -> openPluginView and the open-webui emit to
open-view; pluginPageTitle/Description -> pluginViewTitle/Description;
isPluginPageRoute -> isPluginViewRoute
- Rename i18n keys (pluginWebui -> pluginView, openWebui -> openView,
pluginPageType -> pluginViewType, messages) across en/zh/ja/ru locales
The postMessage bridge channel name (astrbot-plugin-page) is kept
unchanged because it is part of the wire protocol shared with
plugin_page_bridge.js.
* feat(dashboard): authenticate plugin view assets with scoped path tokens
Serve plugin view assets from
/api/v1/plugins/{id}/views/{page}/_t/{token}/... so the scoped token
travels in the URL path. Relative URLs inside the view then inherit the
token through normal path resolution, which removes the need for
server-side HTML/CSS/JS URL rewriting on this route.
- Add the _t routes with a require_plugin_view_token dependency
(v1 paths bypass the dashboard auth middleware)
- Extend the plugin page asset token to a session-length TTL (7 days,
aligned with the dashboard session) with a page_session purpose claim,
still scoped to a single plugin and page
- Serialize the view entry content_path as the _t URL, keeping the
asset_token query for plugins that parse location.search
- Keep the legacy /api/plugin/page/content/...?asset_token= form working
during the transition
- Rename the OpenAPI tag Plugin Pages -> Plugin Views, document the new
route, regenerate the frontend API client, and update the zh/en
plugin-pages docs
* refactor(dashboard): remove the legacy plugin page content route and URL rewriting
The path-token view route makes the compatibility surface unnecessary:
- Drop /api/plugin/page/content/... (query asset_token form)
- Drop the redundant authenticated asset routes /plugins/page/assets,
/plugins/view/assets, and /plugins/{id}/(pages|views)/{page}/assets/...
which had no callers outside the generated client
- Remove the HTML/CSS/JS URL rewriting helpers; view assets are always
served raw and only HTML gets bridge SDK injection plus theme handling
- Slim PluginPageAuth down to the bridge SDK endpoint, the only
non-v1 path still authenticating scoped query tokens
- serialize_plugin_page no longer falls back to the legacy content path
- Update the plugin-pages docs (zh/en) and the OpenAPI spec, and
regenerate the frontend API client
* refactor(dashboard): drop legacy plugin page API aliases
Remove the page-named plugin view API aliases now that the views rename
is complete and legacy compatibility is intentionally dropped:
- DELETE /api/v1/plugins/page(s) and /plugins/{plugin_id}/pages{,/{page_name}}
aliases; /plugins/view(s) and /plugins/{plugin_id}/views{,/{page_name}}
are the only entry config routes
- Remove the unused /api/v1/plugins/page-bridge-sdk.js route; the live
bridge SDK stays at /api/plugin/page/bridge-sdk.js
- Dashboard calls the view-named endpoints; regenerate the API client
and the OpenAPI scopes docs
* refactor(dashboard): keep plugin page APIs as deprecated view aliases
Restore the page-named plugin view routes removed in the previous commit
and keep them for a transition period, with view as the canonical API:
- /plugins/page(s) and /plugins/{plugin_id}/pages{,/{page_name}} serve as
aliases of the corresponding /views routes (same handlers)
- The OpenAPI spec marks the page operations deprecated and points new
clients at the view routes; the v1 page-bridge-sdk.js route is restored
- The dashboard keeps calling the view endpoints by default
* refactor(dashboard): rename the plugin view path parameter to view_name
The view entry and path-token asset routes now use {view_name} instead of
{page_name} in the OpenAPI-visible path, and the service layer renames its
internal page_name variables to match. Wire-compatible surfaces stay
unchanged: the page_name JWT claim, the pageName JSON field, query
parameters, and the deprecated /pages aliases. Error messages and the
plugin view docs (zh/en) complete the Page to View rename.
The Gemini Embedding provider ships enabled by default with
gemini-embedding-exp-03-07 as both the config default and the
source-level fallback. Google retired that experimental model when
gemini-embedding-001 went GA, so a fresh deployment that enables the
provider fails on its first request.
gemini-embedding-001 keeps the configured output_dimensionality path
intact: it supports MRL, so the default 768 dimensions still hold.
Ref #10317
* perf(dashboard): lazy-load VAD dependencies on demand
Load onnxruntime-web and vad-web via dynamic script injection when the
VAD initializes instead of render-blocking script tags in index.html,
so they no longer delay the first paint on every page load.
* perf(dashboard): shrink first-screen bundle and gzip responses
- Lazy-load Monaco Editor (~3.3 MB) via an async wrapper component
instead of importing it in main.ts; the editor bundle now loads only
when an editor is first mounted.
- Load i18n locale data on demand: each language becomes its own async
chunk instead of bundling all four locales (~1.4 MB of JSON) into the
entry.
- Register vue3-apexcharts locally in StatsPage instead of globally in
main.ts, moving ~470 KB out of the entry chunk.
- Add Starlette GZipMiddleware to the dashboard ASGI app so static
assets and API responses are compressed (SSE is excluded by default).
Entry chunk: 4.5 MB -> 648 KB (gzip: 1.25 MB -> 210 KB).
* fix: match command group names as whole words
CommandGroupFilter matched messages with a plain str.startswith, so a
group named "math" also matched "mathematics". Once the group was
restricted to admins, non-admins got the permission-denied reply for any
message that merely started with the group name, such as a normal
private chat message or another plugin's command.
Match group names the same way CommandFilter does: collapse whitespace,
then require an exact match or the name followed by a space.
* fix: normalize command group equality whitespace
Click a staged image thumbnail in the ChatUI input area to open a full-screen preview overlay, matching the behavior of sent message images. The preview state is self-contained within ChatInput so both Chat and StandaloneChat get it without extra wiring.