mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 02:30:16 +08:00
Compare commits
3 Commits
dev
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c5156b01b | ||
|
|
583c3c5727 | ||
|
|
68a6dd725d |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "AstrBot",
|
||||
"desc": "AstrBot's internal plugin, providing some basic capabilities."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "AstrBot",
|
||||
"desc": "AstrBot 的内部插件,提供一些基础能力。"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"display_name": "内置指令",
|
||||
"desc": "AstrBot 自带插件,提供 /reset、/help、/sid 等内置指令。"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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}。使用默认元数据。",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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] === '*'
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
33
dashboard/src/composables/useConfigTextResolver.js
Normal file
33
dashboard/src/composables/useConfigTextResolver.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
60
dashboard/src/utils/pluginI18n.js
Normal file
60
dashboard/src/utils/pluginI18n.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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:
|
||||
|
||||

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

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