Compare commits

...

3 Commits

Author SHA1 Message Date
Soulter
6c5156b01b feat: update config path handling for internationalization support 2026-04-30 23:39:18 +08:00
Soulter
583c3c5727 perf: code quality 2026-04-30 23:37:31 +08:00
Soulter
68a6dd725d feat: Implement plugin internationalization support
- Added support for plugins to provide localized names, descriptions, and configuration texts through JSON files in the `.astrbot-plugin/i18n` directory.
- Updated various components to utilize the new internationalization functions, including `ConfigItemRenderer`, `ExtensionCard`, `ItemCard`, `ObjectEditor`, `PluginSetSelector`, and `TemplateListEditor`.
- Enhanced the `usePluginI18n` utility to resolve plugin-specific translations based on the current locale.
- Modified the `common` store to include an `i18n` field for plugin metadata.
- Updated documentation to include guidelines for plugin internationalization.
- Added tests to ensure proper loading of localization files and integration with plugin metadata.
2026-04-30 23:21:25 +08:00
33 changed files with 720 additions and 67 deletions

View File

@@ -0,0 +1,6 @@
{
"metadata": {
"display_name": "AstrBot",
"desc": "AstrBot's internal plugin, providing some basic capabilities."
}
}

View File

@@ -0,0 +1,6 @@
{
"metadata": {
"display_name": "AstrBot",
"desc": "AstrBot 的内部插件,提供一些基础能力。"
}
}

View File

@@ -0,0 +1,6 @@
{
"metadata": {
"display_name": "Built-in Commands",
"desc": "AstrBot's internal plugin, providing built-in commands such as /reset, /help, and /sid."
}
}

View File

@@ -0,0 +1,6 @@
{
"metadata": {
"display_name": "内置指令",
"desc": "AstrBot 自带插件,提供 /reset、/help、/sid 等内置指令。"
}
}

View File

@@ -67,6 +67,9 @@ class StarMetadata:
astrbot_version: str | None = None
"""插件要求的 AstrBot 版本范围PEP 440 specifier如 >=4.13.0,<4.17.0"""
i18n: dict[str, dict] = field(default_factory=dict)
"""插件自带的国际化文案,按 locale 分组。"""
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"

View File

@@ -13,6 +13,7 @@ import tempfile
import traceback
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from types import ModuleType
import yaml
@@ -513,10 +514,49 @@ class PluginManager:
if isinstance(metadata.get("astrbot_version"), str)
else None
),
i18n=PluginManager._load_plugin_i18n(plugin_path),
)
return metadata
@staticmethod
def _load_plugin_i18n(plugin_path: str) -> dict[str, dict]:
plugin_root = Path(plugin_path)
i18n_dir = plugin_root / ".astrbot-plugin" / "i18n"
if not i18n_dir.is_dir():
return {}
translations: dict[str, dict] = {}
try:
for file_path in i18n_dir.iterdir():
if file_path.suffix.lower() != ".json":
continue
locale = file_path.stem
if not locale or len(locale) > 32:
continue
if not file_path.is_file():
continue
if file_path.stat().st_size > 1024 * 1024:
logger.warning("插件 i18n 文件超过 1MB已跳过: %s", file_path)
continue
try:
with file_path.open(encoding="utf-8") as f:
locale_data = json.load(f)
if isinstance(locale_data, dict):
translations[locale] = locale_data
else:
logger.warning(
"插件 i18n 文件内容不是 JSON object已跳过: %s",
file_path,
)
except Exception as exc:
logger.warning("加载插件 i18n 文件失败 %s: %s", file_path, exc)
except OSError as exc:
logger.warning("读取插件 i18n 目录失败 %s: %s", i18n_dir, exc)
return translations
@staticmethod
def _normalize_plugin_dir_name(plugin_name: str) -> str:
return plugin_name.strip()
@@ -942,6 +982,7 @@ class PluginManager:
metadata.display_name = metadata_yaml.display_name
metadata.support_platforms = metadata_yaml.support_platforms
metadata.astrbot_version = metadata_yaml.astrbot_version
metadata.i18n = metadata_yaml.i18n
except Exception as e:
logger.warning(
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",

View File

@@ -1498,7 +1498,7 @@ class ConfigRoute(Route):
}
async def _get_plugin_config(self, plugin_name: str):
ret: dict = {"metadata": None, "config": None}
ret: dict = {"metadata": None, "config": None, "i18n": {}}
for plugin_md in star_registry:
if plugin_md.name == plugin_name:
@@ -1514,6 +1514,7 @@ class ConfigRoute(Route):
"items": plugin_md.config.schema, # 初始化时通过 __setattr__ 存入了 schema
},
}
ret["i18n"] = plugin_md.i18n
break
return ret

View File

@@ -409,6 +409,7 @@ class PluginRoute(Route):
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
"installed_at": self._get_plugin_installed_at(plugin),
"i18n": plugin.i18n,
}
# 检查是否为全空的幽灵插件
if not any(

View File

@@ -40,6 +40,7 @@ const handleInstall = (plugin) => {
<template>
<v-card
class="rounded-lg d-flex flex-column plugin-card"
variant="outlined"
elevation="0"
>
@@ -238,6 +239,16 @@ const handleInstall = (plugin) => {
</template>
<style scoped>
.plugin-card {
background: rgb(var(--v-theme-surface));
transition: background-color 0.16s ease;
}
.plugin-card:hover,
.plugin-card:focus-within {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.plugin-card-content {
padding: 12px;
padding-bottom: 8px;

View File

@@ -4,6 +4,7 @@ import { ref, computed } from 'vue'
import ConfigItemRenderer from './ConfigItemRenderer.vue'
import TemplateListEditor from './TemplateListEditor.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useConfigTextResolver } from '@/composables/useConfigTextResolver'
import axios from 'axios'
import { useToast } from '@/utils/toast'
@@ -24,6 +25,10 @@ const props = defineProps({
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
pathPrefix: {
type: String,
default: ''
@@ -35,12 +40,9 @@ const props = defineProps({
})
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
}
const { getRaw } = useModuleI18n('features/config-metadata')
const { translateIfKey, resolveConfigText } = useConfigTextResolver(props)
const currentConfigPath = computed(() => props.pathPrefix || props.metadataKey)
const filteredIterable = computed(() => {
if (!props.iterable) return {}
@@ -174,11 +176,11 @@ function hasVisibleItemsAfter(items, currentIndex) {
<template>
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
{{ translateIfKey(metadata[metadataKey]?.description) }} <span class="metadata-key">({{ metadataKey }})</span>
{{ resolveConfigText(currentConfigPath, 'description', metadata[metadataKey]?.description) }} <span class="metadata-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ translateIfKey(metadata[metadataKey]?.hint) }}
{{ resolveConfigText(currentConfigPath, 'hint', metadata[metadataKey]?.hint) }}
</v-list-item-subtitle>
</div>
@@ -207,6 +209,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
:iterable="iterable[key]"
:metadataKey="key"
:pluginName="pluginName"
:pluginI18n="pluginI18n"
:pathPrefix="getItemPath(key)"
>
</AstrBotConfig>
@@ -220,19 +223,22 @@ function hasVisibleItemsAfter(items, currentIndex) {
<div class="config-section mb-2">
<v-list-item-title class="config-title">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ translateIfKey(metadata[metadataKey].items[key]?.description) }}
{{ resolveConfigText(getItemPath(key), 'description', metadata[metadataKey].items[key]?.description) }}
<span class="property-key">({{ key }})</span>
</span>
<span v-else>{{ key }}</span>
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint" class="important-hint"></span>
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }}
{{ resolveConfigText(getItemPath(key), 'hint', metadata[metadataKey].items[key]?.hint) }}
</v-list-item-subtitle>
</div>
<TemplateListEditor
v-model="iterable[key]"
:templates="metadata[metadataKey].items[key]?.templates || {}"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-path="getItemPath(key)"
class="config-field"
/>
</div>
@@ -245,7 +251,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-list-item density="compact">
<v-list-item-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ translateIfKey(metadata[metadataKey].items[key]?.description) }}
{{ resolveConfigText(getItemPath(key), 'description', metadata[metadataKey].items[key]?.description) }}
<span class="property-key">({{ key }})</span>
</span>
<span v-else>{{ key }}</span>
@@ -254,7 +260,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
class="important-hint"></span>
{{ translateIfKey(getItemHint(key, metadata[metadataKey].items[key])) }}
{{ resolveConfigText(getItemPath(key), 'hint', getItemHint(key, metadata[metadataKey].items[key])) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -264,6 +270,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-model="iterable[key]"
:item-meta="metadata[metadataKey].items[key] || null"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="getItemPath(key)"
:loading="loadingEmbeddingDim"
:show-fullscreen-btn="!!metadata[metadataKey].items[key]?.editor_mode"
@@ -287,13 +294,13 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-col cols="12" sm="7" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ metadata[metadataKey]?.description }}
{{ resolveConfigText(getItemPath(metadataKey), 'description', metadata[metadataKey]?.description) }}
<span class="property-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
{{ resolveConfigText(getItemPath(metadataKey), 'hint', metadata[metadataKey]?.hint) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -303,6 +310,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-if="metadata[metadataKey]?.type === 'template_list' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:templates="metadata[metadataKey]?.templates || {}"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-path="getItemPath(metadataKey)"
class="config-field"
/>
<ConfigItemRenderer
@@ -310,6 +320,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-model="iterable[metadataKey]"
:item-meta="metadata[metadataKey]"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="getItemPath(metadataKey)"
/>
</v-col>

View File

@@ -6,6 +6,7 @@ import ConfigItemRenderer from './ConfigItemRenderer.vue'
import TemplateListEditor from './TemplateListEditor.vue'
import PersonaQuickPreview from './PersonaQuickPreview.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useConfigTextResolver } from '@/composables/useConfigTextResolver'
const props = defineProps({
@@ -28,20 +29,15 @@ const props = defineProps({
})
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { getRaw } = useModuleI18n('features/config-metadata')
const { tm: tmConfig } = useModuleI18n('features/config')
const { translateIfKey } = useConfigTextResolver()
const hintMarkdown = new MarkdownIt({
linkify: true,
breaks: true
})
// 翻译器函数 - 如果是国际化键则翻译,否则原样返回
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return tm(value)
}
const renderHint = (value) => {
const text = translateIfKey(value)
if (!text) return ''

View File

@@ -213,6 +213,9 @@
v-else-if="itemMeta?.type === 'dict'"
:model-value="modelValue"
:item-meta="itemMeta"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="configKey"
@update:model-value="emitUpdate"
class="config-field"
/>
@@ -241,6 +244,7 @@ import PluginSetSelector from './PluginSetSelector.vue'
import T2ITemplateEditor from './T2ITemplateEditor.vue'
import { computed, ref } from 'vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { usePluginI18n } from '@/utils/pluginI18n'
const numericTemp = ref(null)
const listSearchText = ref('')
@@ -258,6 +262,10 @@ const props = defineProps({
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
configKey: {
type: String,
default: ''
@@ -275,6 +283,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'get-embedding-dim', 'open-fullscreen'])
const { t } = useI18n()
const { getRaw } = useModuleI18n('features/config-metadata')
const { configText } = usePluginI18n()
function emitUpdate(val) {
emit('update:modelValue', val)
@@ -297,6 +306,17 @@ function getLabel(itemMeta, index, option) {
}
function getTranslatedLabels(itemMeta) {
if (
props.pluginName
&& props.configKey
&& props.pluginI18n
&& Object.keys(props.pluginI18n).length > 0
) {
const translatedLabels = configText(props.pluginI18n, props.configKey, 'labels', null)
if (Array.isArray(translatedLabels)) {
return translatedLabels
}
}
if (!itemMeta?.labels) return null
if (typeof itemMeta.labels === 'string') {
const translatedLabels = getRaw(itemMeta.labels)

View File

@@ -6,6 +6,7 @@ import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
import PluginPlatformChip from "./PluginPlatformChip.vue";
import StyledMenu from "./StyledMenu.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { usePluginI18n } from "@/utils/pluginI18n";
const props = defineProps({
extension: {
@@ -45,6 +46,7 @@ const attrs = useAttrs();
// 国际化
const { tm } = useModuleI18n("features/extension");
const { pluginName, pluginDesc } = usePluginI18n();
const supportPlatforms = computed(() => {
const platforms = props.extension?.support_platforms;
@@ -73,6 +75,10 @@ const logoSrc = computed(() => {
: defaultPluginIcon;
});
const localizedName = computed(() => pluginName(props.extension));
const localizedDesc = computed(() => pluginDesc(props.extension));
watch(
() => props.extension?.logo,
() => {
@@ -170,17 +176,15 @@ const togglePin = () => {
<v-tooltip
location="top"
:text="
extension.display_name?.length &&
extension.display_name !== extension.name
? `${extension.display_name} (${extension.name})`
localizedName?.length &&
localizedName !== extension.name
? `${localizedName} (${extension.name})`
: extension.name
"
>
<template v-slot:activator="{ props: titleTooltipProps }">
<span v-bind="titleTooltipProps" class="extension-title__text">{{
extension.display_name?.length
? extension.display_name
: extension.name
localizedName
}}</span>
</template>
</v-tooltip>
@@ -280,7 +284,7 @@ const togglePin = () => {
class="extension-desc"
:class="{ 'text-caption': $vuetify.display.xs }"
>
{{ extension.desc }}
{{ localizedDesc }}
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<v-card class="item-card hover-elevation" style="padding: 4px;" elevation="0">
<v-card class="item-card hover-elevation" style="padding: 4px;" :variant="variant" elevation="0">
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h2 text-truncate" :title="getItemTitle()">{{ getItemTitle() }}</span>
<v-tooltip location="top">
@@ -116,6 +116,10 @@ export default {
disableDelete: {
type: Boolean,
default: false
},
variant: {
type: String,
default: undefined
}
},
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
@@ -135,9 +139,10 @@ export default {
<style scoped>
.item-card {
background: rgb(var(--v-theme-surface));
position: relative;
border-radius: 18px;
transition: all 0.3s ease;
transition: background-color 0.16s ease, transform 0.3s ease;
overflow: hidden;
min-height: 220px;
height: 100%;
@@ -147,6 +152,7 @@ export default {
}
.hover-elevation:hover {
background: rgba(var(--v-theme-on-surface), 0.04);
transform: translateY(-2px);
}

View File

@@ -115,7 +115,7 @@
<v-col cols="4">
<div class="d-flex flex-column">
<span class="text-caption font-weight-medium">{{ getTemplateTitle(template, templateKey) }}</span>
<span v-if="template.hint" class="text-caption text-grey" style="font-size: 0.7rem;">{{ translateIfKey(template.hint) }}</span>
<span v-if="template.hint" class="text-caption text-grey" style="font-size: 0.7rem;">{{ resolveTemplateText(templateKey, 'hint', template.hint) }}</span>
</div>
</v-col>
<v-col cols="7" class="pl-2 d-flex align-center justify-end">
@@ -221,11 +221,11 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useI18n } from '@/i18n/composables'
import { useToast } from '@/utils/toast'
import { useConfigTextResolver } from '@/composables/useConfigTextResolver'
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { warning: toastWarning } = useToast()
const props = defineProps({
@@ -237,6 +237,18 @@ const props = defineProps({
type: Object,
default: null
},
pluginName: {
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
configKey: {
type: String,
default: ''
},
buttonText: {
type: String,
default: ''
@@ -251,6 +263,8 @@ const props = defineProps({
}
})
const { translateIfKey, resolveConfigText } = useConfigTextResolver(props)
const emit = defineEmits(['update:modelValue'])
const resolveButtonText = computed(() => props.buttonText || t('core.common.list.modifyButton'))
@@ -515,13 +529,15 @@ function cancelDialog() {
dialog.value = false
}
function translateIfKey(value) {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
function getTemplateTitle(template, templateKey) {
return resolveTemplateText(templateKey, 'name', template?.name || template?.description || templateKey)
}
function getTemplateTitle(template, templateKey) {
return translateIfKey(template?.name || template?.description || templateKey)
function resolveTemplateText(templateKey, attr, fallback) {
if (!props.configKey) {
return translateIfKey(fallback) || ''
}
return resolveConfigText(`${props.configKey}.template_schema.${templateKey}`, attr, fallback)
}
</script>
@@ -538,4 +554,3 @@ function getTemplateTitle(template, templateKey) {
opacity: 0.8;
}
</style>

View File

@@ -66,9 +66,9 @@
></v-checkbox>
</template>
<v-list-item-title>{{ plugin.name }}</v-list-item-title>
<v-list-item-title>{{ pluginDisplayName(plugin) }}</v-list-item-title>
<v-list-item-subtitle>
{{ plugin.desc || tm('pluginSetSelector.noDescription') }}
{{ pluginDescription(plugin) || tm('pluginSetSelector.noDescription') }}
<v-chip v-if="!plugin.activated" size="x-small" color="grey" class="ml-1">
{{ tm('pluginSetSelector.notActivated') }}
</v-chip>
@@ -105,6 +105,7 @@
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import { usePluginI18n } from '@/utils/pluginI18n'
const props = defineProps({
modelValue: {
@@ -123,6 +124,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('core.shared')
const { pluginName, pluginDesc } = usePluginI18n()
const dialog = ref(false)
const pluginList = ref([])
@@ -130,6 +132,9 @@ const loading = ref(false)
const selectionMode = ref('custom') // 'all', 'none', 'custom'
const selectedPlugins = ref([])
const pluginDisplayName = (plugin) => pluginName(plugin) || plugin.name
const pluginDescription = (plugin) => pluginDesc(plugin)
// 判断是否为"所有插件"模式
const isAllPlugins = computed(() => {
return props.modelValue && props.modelValue.length === 1 && props.modelValue[0] === '*'

View File

@@ -19,8 +19,8 @@
:key="option.value"
@click="addEntry(option.value)"
>
<v-list-item-title>{{ translateIfKey(option.label) }}</v-list-item-title>
<v-list-item-subtitle v-if="option.hint">{{ translateIfKey(option.hint) }}</v-list-item-subtitle>
<v-list-item-title>{{ option.label }}</v-list-item-title>
<v-list-item-subtitle v-if="option.hint">{{ option.hint }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-menu>
@@ -58,7 +58,7 @@
<div class="d-flex flex-column">
<v-list-item-title class="property-name">{{ templateLabel(entry.__template_key) }}</v-list-item-title>
<v-list-item-subtitle class="property-hint" v-if="getTemplate(entry)?.hint || getTemplate(entry)?.description">
{{ translateIfKey(getTemplate(entry)?.hint || getTemplate(entry)?.description) }}
{{ templateText(entry.__template_key, 'hint', getTemplate(entry)?.hint || getTemplate(entry)?.description) }}
</v-list-item-subtitle>
</div>
</div>
@@ -82,10 +82,10 @@
>
<div class="config-section mb-2">
<v-list-item-title class="config-title">
{{ translateIfKey(itemMeta?.description) || itemKey }}
{{ templateItemText(entry.__template_key, itemKey, 'description', itemMeta?.description) || itemKey }}
</v-list-item-title>
<v-list-item-subtitle class="config-hint" v-if="itemMeta?.hint">
{{ translateIfKey(itemMeta.hint) }}
{{ templateItemText(entry.__template_key, itemKey, 'hint', itemMeta.hint) }}
</v-list-item-subtitle>
</div>
<div v-for="(childMeta, childKey, childIndex) in itemMeta.items" :key="childKey">
@@ -94,10 +94,10 @@
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ translateIfKey(childMeta?.description) || childKey }}
{{ templateItemText(entry.__template_key, `${itemKey}.${childKey}`, 'description', childMeta?.description) || childKey }}
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
{{ translateIfKey(childMeta?.hint) }}
{{ templateItemText(entry.__template_key, `${itemKey}.${childKey}`, 'hint', childMeta?.hint) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -105,6 +105,9 @@
<ConfigItemRenderer
v-model="entry[itemKey][childKey]"
:item-meta="childMeta"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="templateItemPath(entry.__template_key, `${itemKey}.${childKey}`)"
/>
</v-col>
</v-row>
@@ -122,11 +125,11 @@
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
<span v-if="itemMeta?.description">{{ translateIfKey(itemMeta?.description) }} <span class="property-key">({{ itemKey }})</span></span>
<span v-if="itemMeta?.description">{{ templateItemText(entry.__template_key, itemKey, 'description', itemMeta?.description) }} <span class="property-key">({{ itemKey }})</span></span>
<span v-else>{{ itemKey }}</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
{{ translateIfKey(itemMeta?.hint) }}
{{ templateItemText(entry.__template_key, itemKey, 'hint', itemMeta?.hint) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -134,6 +137,9 @@
<ConfigItemRenderer
v-model="entry[itemKey]"
:item-meta="itemMeta"
:plugin-name="pluginName"
:plugin-i18n="pluginI18n"
:config-key="templateItemPath(entry.__template_key, itemKey)"
/>
</v-col>
</v-row>
@@ -153,7 +159,8 @@
<script setup>
import { computed, ref, watch } from 'vue'
import ConfigItemRenderer from './ConfigItemRenderer.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useI18n } from '@/i18n/composables'
import { useConfigTextResolver } from '@/composables/useConfigTextResolver'
const props = defineProps({
modelValue: {
@@ -163,12 +170,24 @@ const props = defineProps({
templates: {
type: Object,
default: () => ({})
},
pluginName: {
type: String,
default: ''
},
pluginI18n: {
type: Object,
default: () => ({})
},
configPath: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { resolveConfigText } = useConfigTextResolver(props)
const expandedEntries = ref({})
@@ -188,20 +207,31 @@ const defaultValueMap = {
const templateOptions = computed(() => {
return Object.entries(props.templates || {}).map(([value, meta]) => ({
label: meta?.name || value,
label: templateText(value, 'name', meta?.name || value),
value,
hint: meta?.hint || meta?.description || ''
hint: templateText(value, 'hint', meta?.hint || meta?.description || '')
}))
})
function templateLabel(key) {
if (!key) return t('core.common.templateList.unknownTemplate') || '未指定模板'
return translateIfKey(props.templates?.[key]?.name || key)
return templateText(key, 'name', props.templates?.[key]?.name || key)
}
function translateIfKey(value) {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
function templatePath(templateKey) {
return props.configPath ? `${props.configPath}.templates.${templateKey}` : `templates.${templateKey}`
}
function templateItemPath(templateKey, itemPath) {
return `${templatePath(templateKey)}.${itemPath}`
}
function templateText(templateKey, attr, fallback) {
return resolveConfigText(templatePath(templateKey), attr, fallback)
}
function templateItemText(templateKey, itemPath, attr, fallback) {
return resolveConfigText(templateItemPath(templateKey, itemPath), attr, fallback)
}
function buildDefaults(itemsMeta = {}) {

View File

@@ -0,0 +1,33 @@
import { useModuleI18n } from '@/i18n/composables'
import { usePluginI18n } from '@/utils/pluginI18n'
export function useConfigTextResolver(props = {}) {
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { configText } = usePluginI18n()
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
}
const hasPluginI18n = () => {
return Boolean(
props.pluginName
&& props.pluginI18n
&& Object.keys(props.pluginI18n).length > 0,
)
}
const resolveConfigText = (path, attr, fallback) => {
const fallbackText = translateIfKey(fallback) || ''
if (!hasPluginI18n()) {
return fallbackText
}
return configText(props.pluginI18n, path, attr, fallbackText)
}
return {
translateIfKey,
resolveConfigText,
}
}

View File

@@ -176,6 +176,7 @@ export const useCommonStore = defineStore("common", {
"stars": pluginData?.stars ? pluginData.stars : 0,
"updated_at": pluginData?.updated_at ? pluginData.updated_at : "",
"display_name": pluginData?.display_name ? pluginData.display_name : "",
"i18n": pluginData?.i18n && typeof pluginData.i18n === 'object' ? pluginData.i18n : {},
"astrbot_version": pluginData?.astrbot_version ? pluginData.astrbot_version : "",
"category": pluginData?.category ? pluginData.category : "",
"support_platforms": Array.isArray(pluginData?.support_platforms)

View File

@@ -0,0 +1,60 @@
import { useI18n } from '@/i18n/composables'
function getLocaleData(i18n, locale) {
if (!i18n || typeof i18n !== 'object' || !locale) return null
return i18n[locale] || null
}
function getByPath(source, key) {
if (!source || typeof source !== 'object' || !key) return undefined
const parts = key.split('.')
let current = source
for (const part of parts) {
if (!current || typeof current !== 'object' || !(part in current)) {
return undefined
}
current = current[part]
}
return current
}
export function resolvePluginI18n(i18n, locale, key, fallback = '') {
const localeData = getLocaleData(i18n, locale)
const value = getByPath(localeData, key)
return value === undefined || value === null ? fallback : value
}
export function usePluginI18n() {
const { locale } = useI18n()
const resolve = (i18n, key, fallback = '') => {
return resolvePluginI18n(i18n, locale.value, key, fallback)
}
const pluginName = (plugin) => {
const fallback = plugin?.display_name?.length ? plugin.display_name : plugin?.name
return resolve(plugin?.i18n, 'metadata.display_name', fallback || '')
}
const pluginDesc = (plugin, fallback = '') => {
return resolve(
plugin?.i18n,
'metadata.desc',
fallback || plugin?.desc || plugin?.description || '',
)
}
const configText = (i18n, path, attr, fallback = '') => {
const key = path ? `config.${path}.${attr}` : `config.${attr}`
return resolve(i18n, key, fallback)
}
return {
locale,
resolve,
pluginName,
pluginDesc,
configText,
}
}

View File

@@ -331,6 +331,7 @@ const selectedMarketPlugin = computed(() => {
:iterable="extension_config.config"
:metadataKey="curr_namespace"
:pluginName="curr_namespace"
:pluginI18n="extension_config.i18n"
/>
<p v-else>{{ tm("dialogs.config.noConfig") }}</p>
</div>

View File

@@ -27,6 +27,7 @@
<v-row v-else>
<v-col v-for="(platform, index) in config_data.platform || []" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card :item="platform" title-field="id" enabled-field="enable"
variant="outlined"
:bglogo="getPlatformIcon(platform.type || platform.id)" @toggle-enabled="platformStatusChange"
@delete="deletePlatform" @edit="editPlatform">
<template #item-details="{ item }">

View File

@@ -4,6 +4,7 @@ import axios from "axios";
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { usePluginI18n } from "@/utils/pluginI18n";
const props = defineProps({
plugin: {
@@ -21,6 +22,7 @@ const props = defineProps({
});
const { tm, router } = props.state;
const { pluginName, pluginDesc: resolvePluginDesc } = usePluginI18n();
const markdown = new MarkdownIt({
html: true,
@@ -75,9 +77,7 @@ const logoLoadFailed = ref(false);
const detailPageRef = ref(null);
const isHeaderStuck = ref(false);
const displayName = computed(() =>
props.plugin.display_name?.length ? props.plugin.display_name : props.plugin.name,
);
const displayName = computed(() => pluginName(props.plugin));
const pluginDesc = computed(() => {
const desc =
@@ -86,7 +86,7 @@ const pluginDesc = computed(() => {
props.marketPlugin?.desc ||
props.marketPlugin?.description ||
"";
return String(desc || "").trim();
return String(resolvePluginDesc(props.plugin, desc) || "").trim();
});
const logoSrc = computed(() => {

View File

@@ -127,6 +127,7 @@ export const useExtensionPage = () => {
const extension_config = reactive({
metadata: {},
config: {},
i18n: {},
});
const pluginMarketData = ref([]);
const loadingDialog = reactive({
@@ -138,6 +139,7 @@ export const useExtensionPage = () => {
const showPluginInfoDialog = ref(false);
const selectedPlugin = ref({});
const curr_namespace = ref("");
const currentConfigPlugin = ref("");
const updatingAll = ref(false);
const readmeDialog = reactive({
@@ -854,6 +856,7 @@ export const useExtensionPage = () => {
const openExtensionConfig = async (extension_name) => {
curr_namespace.value = extension_name;
currentConfigPlugin.value = extension_name;
configDialog.value = true;
try {
const res = await axios.get(
@@ -861,6 +864,7 @@ export const useExtensionPage = () => {
);
extension_config.metadata = res.data.data.metadata;
extension_config.config = res.data.data.config;
extension_config.i18n = res.data.data.i18n || {};
} catch (err) {
toast(err, "error");
}
@@ -878,8 +882,10 @@ export const useExtensionPage = () => {
toast(res.data.message, "error");
}
configDialog.value = false;
currentConfigPlugin.value = "";
extension_config.metadata = {};
extension_config.config = {};
extension_config.i18n = {};
getExtensions();
} catch (err) {
toast(err, "error");

View File

@@ -1,6 +1,6 @@
<template>
<v-card class="persona-card" :class="{ 'dragging': isDragging }" rounded="lg" @click="$emit('view')" elevation="0"
draggable="true" @dragstart="handleDragStart" @dragend="handleDragEnd">
<v-card class="persona-card" :class="{ 'dragging': isDragging }" rounded="lg" variant="outlined" @click="$emit('view')"
elevation="0" draggable="true" @dragstart="handleDragStart" @dragend="handleDragEnd">
<v-card-title class="d-flex justify-space-between align-center">
<div class="text-truncate ml-2">{{ persona.persona_id }}</div>
<v-menu offset-y>
@@ -142,9 +142,15 @@ export default defineComponent({
<style scoped>
.persona-card {
background: rgb(var(--v-theme-surface));
height: 100%;
cursor: grab;
transition: all 0.2s ease;
transition: background-color 0.16s ease, opacity 0.2s ease, transform 0.2s ease;
}
.persona-card:hover,
.persona-card:focus-within {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.persona-card:active {

View File

@@ -191,6 +191,7 @@ export default defineConfig({
{ text: "接收消息事件", link: "/guides/listen-message-event" },
{ text: "发送消息", link: "/guides/send-message" },
{ text: "插件配置", link: "/guides/plugin-config" },
{ text: "插件国际化", link: "/guides/plugin-i18n" },
{ text: "调用 AI", link: "/guides/ai" },
{ text: "存储", link: "/guides/storage" },
{ text: "文转图", link: "/guides/html-to-pic" },
@@ -433,6 +434,7 @@ export default defineConfig({
{ text: "Listen to Message Events", link: "/guides/listen-message-event" },
{ text: "Send Messages", link: "/guides/send-message" },
{ text: "Plugin Configuration", link: "/guides/plugin-config" },
{ text: "Plugin Internationalization", link: "/guides/plugin-i18n" },
{ text: "AI", link: "/guides/ai" },
{ text: "Storage", link: "/guides/storage" },
{ text: "HTML to Image", link: "/guides/html-to-pic" },

View File

@@ -56,6 +56,10 @@ The file content is a `Schema` that represents the configuration. The Schema is
- `editor_theme`: Optional. The theme for the code editor. Options are `vs-light` (default) and `vs-dark`.
- `_special`: Optional. Used to call AstrBot's visualization features for provider selection, persona selection, knowledge base selection, etc. See details below.
### Configuration Internationalization (Optional)
Configuration `description`, `hint`, and select `labels` can follow the WebUI language. See [Plugin Internationalization](./plugin-i18n).
When the code editor is enabled, it looks like this:
![editor_mode](https://files.astrbot.app/docs/source/images/plugin/image-6.png)
@@ -216,4 +220,4 @@ class ConfigPlugin(Star):
## Configuration Updates
When you update the Schema across different versions, AstrBot will recursively inspect the configuration items in the Schema, automatically adding default values for missing items and removing those that no longer exist.
When you update the Schema across different versions, AstrBot will recursively inspect the configuration items in the Schema, automatically adding default values for missing items and removing those that no longer exist.

View File

@@ -0,0 +1,145 @@
# Plugin Internationalization
Plugins can provide `.astrbot-plugin/i18n/*.json` files in their own directory so the WebUI can display plugin names, descriptions, and configuration text in the current language.
## Directory Structure
```text
your_plugin/
metadata.yaml
_conf_schema.json
.astrbot-plugin/
i18n/
zh-CN.json
en-US.json
```
Locale file names use WebUI locales, such as `zh-CN.json` and `en-US.json`. Each file must contain a JSON object.
When the current locale has no translation, a field is missing, or the locale file does not exist, AstrBot falls back to the default text:
- Plugin names and descriptions fall back to `display_name` and `desc` in `metadata.yaml`.
- Configuration text falls back to `description`, `hint`, and `labels` in `_conf_schema.json`.
## Metadata
`metadata` overrides the plugin name and description shown on plugin pages.
```json
{
"metadata": {
"display_name": "Weather Assistant",
"desc": "Query weather and provide travel suggestions."
}
}
```
## Configuration
`config` overrides text from `_conf_schema.json`. The structure is nested by configuration item name.
Example `_conf_schema.json`:
```json
{
"enable": {
"description": "Enable",
"type": "bool",
"hint": "Whether to enable this plugin.",
"default": true
},
"mode": {
"description": "Mode",
"type": "string",
"options": ["fast", "safe"],
"labels": ["Fast", "Safe"]
}
}
```
Corresponding `.astrbot-plugin/i18n/zh-CN.json`:
```json
{
"config": {
"enable": {
"description": "启用",
"hint": "是否启用这个插件。"
},
"mode": {
"description": "模式",
"labels": ["快速", "安全"]
}
}
}
```
`options` are stored configuration values and should usually not be translated. Use `labels` for select display text.
## Nested Configuration
For `object` items in `_conf_schema.json`, translations use the same nested field structure.
```json
{
"config": {
"sub_config": {
"name": {
"description": "Name",
"hint": "The name shown in messages."
}
}
}
}
```
## Template Lists
`template_list` template names and fields can also be translated. Put template names under `templates.<template>.name`, then continue nesting for fields inside the template.
```json
{
"config": {
"rules": {
"description": "Rules",
"templates": {
"default": {
"name": "Default template",
"threshold": {
"description": "Threshold",
"hint": "Triggers the rule after reaching this value."
}
}
}
}
}
}
```
## Complete Example
Here is an English translation example for a real configuration:
```json
{
"metadata": {
"display_name": "HAPI Vibe Coding Remote",
"desc": "Connect to a HAPI service and control coding agent sessions from chat platforms."
},
"config": {
"hapi_endpoint": {
"description": "HAPI service URL",
"hint": "Example: http://localhost:3006"
},
"output_level": {
"description": "SSE delivery level",
"hint": "silence: permission requests only; simple: plain text messages and system events; summary: recent N messages when a task completes; detail: all messages in real time",
"labels": ["Silence", "Simple", "Summary", "Detail"]
}
}
}
```
## Constraints
Plugin internationalization only reads the `.astrbot-plugin/i18n` directory. Locale files must use nested JSON objects; dot-key flat entries are not supported.

View File

@@ -51,6 +51,8 @@ You can add a `logo.png` file in the plugin directory as the plugin's logo. Plea
You can modify (or add) the `display_name` field in the `metadata.yaml` file to serve as the plugin's display name in scenarios like the plugin marketplace, making it easier for users to read.
Plugin display names and descriptions can follow the WebUI language. See [Plugin Internationalization](./guides/plugin-i18n).
### Declare Supported Platforms (Optional)
You can add a `support_platforms` field (`list[str]`) to `metadata.yaml` to declare which platform adapters your plugin supports. The WebUI plugin page will display this field.

View File

@@ -56,6 +56,10 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
- `editor_theme`: 可选。代码编辑器的主题,可选值有 `vs-light`(默认), `vs-dark`
- `_special`: 可选。用于调用 AstrBot 提供的可视化提供商选取、人格选取、知识库选取等功能,详见下文。
### 配置项国际化(可选)
配置项的 `description``hint` 和下拉选项 `labels` 支持按 WebUI 语言显示,详见[插件国际化](./plugin-i18n)。
其中,如果启用了代码编辑器,效果如下图所示:
![editor_mode](https://files.astrbot.app/docs/source/images/plugin/image-6.png)

View File

@@ -0,0 +1,145 @@
# 插件国际化
插件可以在自己的目录下提供 `.astrbot-plugin/i18n/*.json`,让 WebUI 根据当前语言显示插件名称、描述和配置项文案。
## 目录结构
```text
your_plugin/
metadata.yaml
_conf_schema.json
.astrbot-plugin/
i18n/
zh-CN.json
en-US.json
```
语言文件名使用 WebUI 的 locale例如 `zh-CN.json``en-US.json`。文件内容必须是 JSON object。
当当前语言没有对应翻译、某个字段缺失或语言文件不存在时AstrBot 会回退到默认文案:
- 插件名称和描述回退到 `metadata.yaml` 中的 `display_name``desc`
- 配置项文案回退到 `_conf_schema.json` 中的 `description``hint``labels`
## 元数据
`metadata` 用于覆盖插件在插件页展示的名称和描述。
```json
{
"metadata": {
"display_name": "天气助手",
"desc": "查询天气并提供出行建议。"
}
}
```
## 配置项
`config` 用于覆盖 `_conf_schema.json` 中的配置文案。结构按配置项名称嵌套。
例如 `_conf_schema.json`
```json
{
"enable": {
"description": "Enable",
"type": "bool",
"hint": "Whether to enable this plugin.",
"default": true
},
"mode": {
"description": "Mode",
"type": "string",
"options": ["fast", "safe"],
"labels": ["Fast", "Safe"]
}
}
```
对应 `.astrbot-plugin/i18n/zh-CN.json`
```json
{
"config": {
"enable": {
"description": "启用",
"hint": "是否启用这个插件。"
},
"mode": {
"description": "模式",
"labels": ["快速", "安全"]
}
}
}
```
`options` 是配置保存值,不建议翻译。下拉框的展示文本请使用 `labels`
## 嵌套配置
如果 `_conf_schema.json` 中有 `object` 类型配置,翻译也按同样的字段结构继续嵌套。
```json
{
"config": {
"sub_config": {
"name": {
"description": "名称",
"hint": "显示在消息中的名称。"
}
}
}
}
```
## 模板列表
`template_list` 的模板名称和模板内字段也可以翻译。模板名称放在 `templates.<模板名>.name`,模板内字段继续往下嵌套。
```json
{
"config": {
"rules": {
"description": "规则",
"templates": {
"default": {
"name": "默认模板",
"threshold": {
"description": "阈值",
"hint": "达到该值后触发规则。"
}
}
}
}
}
}
```
## 完整示例
下面是一个真实配置项的英文翻译示例:
```json
{
"metadata": {
"display_name": "HAPI Vibe Coding Remote",
"desc": "Connect to a HAPI service and control coding agent sessions from chat platforms."
},
"config": {
"hapi_endpoint": {
"description": "HAPI service URL",
"hint": "Example: http://localhost:3006"
},
"output_level": {
"description": "SSE delivery level",
"hint": "silence: permission requests only; simple: plain text messages and system events; summary: recent N messages when a task completes; detail: all messages in real time",
"labels": ["Silence", "Simple", "Summary", "Detail"]
}
}
}
```
## 约束
插件国际化只读取 `.astrbot-plugin/i18n` 目录。语言文件必须使用嵌套 JSON 结构,不支持点号扁平 key。

View File

@@ -53,6 +53,8 @@ git clone 插件仓库地址
可以修改(或添加) `metadata.yaml` 文件中的 `display_name` 字段,作为插件在插件市场等场景中的展示名,以方便用户阅读。
插件展示名和描述支持按 WebUI 语言显示,详见[插件国际化](./guides/plugin-i18n)。
### 声明支持平台Optional
你可以在 `metadata.yaml` 中新增 `support_platforms` 字段(`list[str]`声明插件支持的平台适配器。WebUI 插件页会展示该字段。

View File

@@ -1,4 +1,5 @@
import asyncio
import json
import os
from pathlib import Path
from typing import Any, cast
@@ -52,6 +53,78 @@ def _write_requirements(plugin_path: Path):
f.write("networkx\n")
def test_load_plugin_i18n_reads_locale_files(tmp_path: Path):
plugin_path = tmp_path / "plugin"
i18n_path = plugin_path / ".astrbot-plugin" / "i18n"
i18n_path.mkdir(parents=True)
(i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"desc": "中文描述"}}, ensure_ascii=False),
encoding="utf-8",
)
(i18n_path / "en-US.json").write_text(
json.dumps({"metadata": {"desc": "English description"}}),
encoding="utf-8",
)
(i18n_path / "README.md").write_text("ignored", encoding="utf-8")
assert PluginManager._load_plugin_i18n(str(plugin_path)) == {
"zh-CN": {"metadata": {"desc": "中文描述"}},
"en-US": {"metadata": {"desc": "English description"}},
}
def test_load_plugin_i18n_ignores_legacy_directories(tmp_path: Path):
plugin_path = tmp_path / "plugin"
hidden_legacy_i18n_path = plugin_path / ".i18n"
legacy_i18n_path = plugin_path / "i18n"
hidden_legacy_i18n_path.mkdir(parents=True)
legacy_i18n_path.mkdir()
(hidden_legacy_i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"desc": "隐藏旧目录"}}, ensure_ascii=False),
encoding="utf-8",
)
(legacy_i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"desc": "中文描述"}}, ensure_ascii=False),
encoding="utf-8",
)
assert PluginManager._load_plugin_i18n(str(plugin_path)) == {}
def test_load_plugin_metadata_includes_i18n(tmp_path: Path):
plugin_path = tmp_path / "helloworld"
_write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO)
i18n_path = plugin_path / ".astrbot-plugin" / "i18n"
i18n_path.mkdir(parents=True)
(i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"display_name": "你好世界"}}, ensure_ascii=False),
encoding="utf-8",
)
metadata = PluginManager._load_plugin_metadata(str(plugin_path))
assert metadata is not None
assert metadata.i18n == {"zh-CN": {"metadata": {"display_name": "你好世界"}}}
def test_loaded_metadata_can_copy_i18n_into_existing_star_metadata(tmp_path: Path):
plugin_path = tmp_path / "helloworld"
_write_local_test_plugin(plugin_path, TEST_PLUGIN_REPO)
i18n_path = plugin_path / ".astrbot-plugin" / "i18n"
i18n_path.mkdir(parents=True)
(i18n_path / "zh-CN.json").write_text(
json.dumps({"metadata": {"desc": "中文描述"}}, ensure_ascii=False),
encoding="utf-8",
)
existing_metadata = star_manager_module.StarMetadata(name="old")
loaded_metadata = PluginManager._load_plugin_metadata(str(plugin_path))
assert loaded_metadata is not None
existing_metadata.i18n = loaded_metadata.i18n
assert existing_metadata.i18n == {"zh-CN": {"metadata": {"desc": "中文描述"}}}
def _clear_module_cache():
"""Clear test-specific modules from sys.modules to allow reloading."""
import sys