mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
chore: misc fixes and new test files
- fix(openai_source): remove dead code comment - fix(common.ts): use resolveApiUrl helper for live-log endpoint - style(mdi-subset): clean up icon CSS - feat: add translation check script and unit tests
This commit is contained in:
@@ -820,7 +820,6 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
model = model or self.get_model()
|
||||
payloads = {**kwargs, "messages": context_query, "model": model}
|
||||
# Filter out non-JSON-serializable objects (e.g. asyncio.Event from abort_signal)
|
||||
payloads.pop("abort_signal", None)
|
||||
|
||||
self._finally_convert_payload(payloads)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 249 icons */
|
||||
/* Auto-generated MDI subset – 250 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -664,6 +664,10 @@
|
||||
content: "\F0375";
|
||||
}
|
||||
|
||||
.mdi-moon-waning-crescent::before {
|
||||
content: "\F0F65";
|
||||
}
|
||||
|
||||
.mdi-note-text-outline::before {
|
||||
content: "\F11D7";
|
||||
}
|
||||
|
||||
@@ -67,14 +67,14 @@
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
v-else-if="
|
||||
renderPart.part.type === 'plain' &&
|
||||
renderPart.part.text &&
|
||||
renderPart.part.text.trim()
|
||||
renderPart.type === 'part' &&
|
||||
renderPart.part?.type === 'plain' &&
|
||||
renderPart.part?.text?.trim()
|
||||
"
|
||||
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
|
||||
custom-id="message-list"
|
||||
:custom-html-tags="['ref']"
|
||||
:content="normalizeMarkdownContent(renderPart.part.text)"
|
||||
:content="normalizeMarkdownContent(renderPart.part?.text)"
|
||||
:typewriter="false"
|
||||
class="markdown-content"
|
||||
:is-dark="isDark"
|
||||
@@ -84,14 +84,14 @@
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
v-else-if="
|
||||
renderPart.part.type === 'plain' &&
|
||||
renderPart.part.text &&
|
||||
renderPart.part.text.trim()
|
||||
renderPart.type === 'part' &&
|
||||
renderPart.part?.type === 'plain' &&
|
||||
renderPart.part?.text?.trim()
|
||||
"
|
||||
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
|
||||
custom-id="message-list"
|
||||
:custom-html-tags="['ref']"
|
||||
:content="renderPart.part.text"
|
||||
:content="renderPart.part?.text"
|
||||
:typewriter="false"
|
||||
class="markdown-content"
|
||||
:is-dark="isDark"
|
||||
@@ -100,15 +100,16 @@
|
||||
<!-- Image -->
|
||||
<div
|
||||
v-else-if="
|
||||
renderPart.part.type === 'image' && renderPart.part.embedded_url
|
||||
renderPart.type === 'part' &&
|
||||
renderPart.part?.type === 'image' && renderPart.part?.embedded_url
|
||||
"
|
||||
class="embedded-images"
|
||||
>
|
||||
<div class="embedded-image">
|
||||
<img
|
||||
:src="renderPart.part.embedded_url"
|
||||
:src="renderPart.part?.embedded_url"
|
||||
class="bot-embedded-image"
|
||||
@click="emitOpenImage(renderPart.part.embedded_url)"
|
||||
@click="emitOpenImage(renderPart.part?.embedded_url)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,12 +117,13 @@
|
||||
<!-- Audio -->
|
||||
<div
|
||||
v-else-if="
|
||||
renderPart.part.type === 'record' && renderPart.part.embedded_url
|
||||
renderPart.type === 'part' &&
|
||||
renderPart.part?.type === 'record' && renderPart.part?.embedded_url
|
||||
"
|
||||
class="embedded-audio"
|
||||
>
|
||||
<audio controls class="audio-player">
|
||||
<source :src="renderPart.part.embedded_url" type="audio/wav" />
|
||||
<source :src="renderPart.part?.embedded_url" type="audio/wav" />
|
||||
{{ t("messages.errors.browser.audioNotSupported") }}
|
||||
</audio>
|
||||
</div>
|
||||
@@ -129,15 +131,16 @@
|
||||
<!-- Files -->
|
||||
<div
|
||||
v-else-if="
|
||||
renderPart.part.type === 'file' && renderPart.part.embedded_file
|
||||
renderPart.type === 'part' &&
|
||||
renderPart.part?.type === 'file' && renderPart.part?.embedded_file
|
||||
"
|
||||
class="embedded-files"
|
||||
>
|
||||
<div class="embedded-file">
|
||||
<a
|
||||
v-if="renderPart.part.embedded_file.url"
|
||||
:href="renderPart.part.embedded_file.url"
|
||||
:download="renderPart.part.embedded_file.filename"
|
||||
v-if="renderPart.part?.embedded_file?.url"
|
||||
:href="renderPart.part?.embedded_file?.url"
|
||||
:download="renderPart.part?.embedded_file?.filename"
|
||||
class="file-link"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
:style="
|
||||
@@ -157,7 +160,7 @@
|
||||
>mdi-file-document-outline</v-icon
|
||||
>
|
||||
<span class="file-name">{{
|
||||
renderPart.part.embedded_file.filename
|
||||
renderPart.part?.embedded_file?.filename
|
||||
}}</span>
|
||||
</a>
|
||||
<a
|
||||
@@ -173,7 +176,7 @@
|
||||
}
|
||||
: {}
|
||||
"
|
||||
@click="emitDownloadFile(renderPart.part.embedded_file)"
|
||||
@click="emitDownloadFile(renderPart.part?.embedded_file)"
|
||||
>
|
||||
<v-icon
|
||||
size="small"
|
||||
@@ -182,11 +185,11 @@
|
||||
>mdi-file-document-outline</v-icon
|
||||
>
|
||||
<span class="file-name">{{
|
||||
renderPart.part.embedded_file.filename
|
||||
renderPart.part?.embedded_file?.filename
|
||||
}}</span>
|
||||
<v-icon
|
||||
v-if="
|
||||
downloadingFiles?.has(renderPart.part.embedded_file.attachment_id)
|
||||
downloadingFiles?.has(renderPart.part?.embedded_file?.attachment_id)
|
||||
"
|
||||
size="small"
|
||||
class="download-icon"
|
||||
@@ -346,6 +349,7 @@ const getRenderParts = (messageParts) => {
|
||||
}
|
||||
|
||||
flushPending(idx - 1);
|
||||
if (!part) return;
|
||||
rendered.push({
|
||||
type: "part",
|
||||
part,
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import axios from "axios";
|
||||
import axios from "@/utils/request";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, reactive, type Ref } from "vue";
|
||||
import axios from "@/utils/request";
|
||||
import { resolveWebSocketUrl } from "@/utils/request";
|
||||
import { resolveWebSocketUrl, resolveApiUrl } from "@/utils/request";
|
||||
import { useToast } from "@/utils/toast";
|
||||
|
||||
// 工具调用信息
|
||||
@@ -842,7 +842,7 @@ export function useMessages(
|
||||
const controller = new AbortController();
|
||||
currentRequestController.value = controller;
|
||||
|
||||
const response = await fetch("/api/chat/send", {
|
||||
const response = await fetch(resolveApiUrl("/api/chat/send"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1034,7 +1034,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import axios from "@/utils/request";
|
||||
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
|
||||
@@ -30,6 +30,43 @@
|
||||
:subtitle="tm('theme.subtitle')"
|
||||
:title="tm('theme.customize.title')"
|
||||
>
|
||||
<!-- Theme Mode Toggle (Light/Dark) -->
|
||||
<v-row class="mt-2" density="compact" align="center">
|
||||
<v-col cols="12" sm="4">
|
||||
<v-btn-toggle
|
||||
v-model="isDarkMode"
|
||||
mandatory
|
||||
density="compact"
|
||||
color="primary"
|
||||
@update:model-value="toggleThemeMode"
|
||||
>
|
||||
<v-btn value="light" size="small">
|
||||
<v-icon class="mr-1">mdi-white-balance-sunny</v-icon>
|
||||
亮色
|
||||
</v-btn>
|
||||
<v-btn value="dark" size="small">
|
||||
<v-icon class="mr-1">mdi-moon-waning-crescent</v-icon>
|
||||
暗色
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Theme Preset Selector -->
|
||||
<v-row class="mt-2" density="compact">
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="selectedThemePreset"
|
||||
:items="presetOptions"
|
||||
:label="tm('theme.customize.preset')"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
@update:model-value="applyThemePreset"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-2" density="compact">
|
||||
<v-col cols="4" sm="2">
|
||||
<v-text-field
|
||||
@@ -286,7 +323,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import axios from "axios";
|
||||
import axios from "@/utils/request";
|
||||
import WaitingForRestart from "@/components/shared/WaitingForRestart.vue";
|
||||
import ProxySelector from "@/components/shared/ProxySelector.vue";
|
||||
import MigrationDialog from "@/components/shared/MigrationDialog.vue";
|
||||
@@ -296,12 +333,24 @@ import StorageCleanupPanel from "@/components/shared/StorageCleanupPanel.vue";
|
||||
import { restartAstrBot as restartAstrBotRuntime } from "@/utils/restartAstrBot";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { useTheme } from "vuetify";
|
||||
import { PurpleTheme } from "@/theme/LightTheme";
|
||||
import { BlueBusinessLightTheme } from "@/theme/BlueBusinessLightTheme";
|
||||
import { LIGHT_THEME_NAME, DARK_THEME_NAME } from "@/theme/constants";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
|
||||
const { tm } = useModuleI18n("features/settings");
|
||||
const toastStore = useToastStore();
|
||||
const theme = useTheme();
|
||||
const customizer = useCustomizerStore();
|
||||
|
||||
// Theme mode toggle (light/dark)
|
||||
const isDarkMode = ref(customizer.isDarkTheme ? "dark" : "light");
|
||||
|
||||
const toggleThemeMode = (mode: string) => {
|
||||
const newTheme = mode === "dark" ? DARK_THEME_NAME : LIGHT_THEME_NAME;
|
||||
customizer.SET_UI_THEME(newTheme);
|
||||
vuetify.theme.change(newTheme);
|
||||
};
|
||||
|
||||
const getStoredColor = (key, fallback) => {
|
||||
const stored =
|
||||
@@ -310,12 +359,92 @@ const getStoredColor = (key, fallback) => {
|
||||
};
|
||||
|
||||
const primaryColor = ref(
|
||||
getStoredColor("themePrimary", PurpleTheme.colors.primary),
|
||||
getStoredColor("themePrimary", BlueBusinessLightTheme.colors.primary),
|
||||
);
|
||||
const secondaryColor = ref(
|
||||
getStoredColor("themeSecondary", PurpleTheme.colors.secondary),
|
||||
getStoredColor("themeSecondary", BlueBusinessLightTheme.colors.secondary),
|
||||
);
|
||||
|
||||
// Theme presets based on MD3 color system
|
||||
const themePresets = [
|
||||
{
|
||||
id: "blue-business",
|
||||
name: "活力商务蓝",
|
||||
nameEn: "Business Blue",
|
||||
primary: "#005FB0",
|
||||
secondary: "#565E71",
|
||||
tertiary: "#006B5B",
|
||||
},
|
||||
{
|
||||
id: "purple-default",
|
||||
name: "优雅紫",
|
||||
nameEn: "Elegant Purple",
|
||||
primary: "#6750A4",
|
||||
secondary: "#625B71",
|
||||
tertiary: "#7D5260",
|
||||
},
|
||||
{
|
||||
id: "teal-fresh",
|
||||
name: "自然清新绿",
|
||||
nameEn: "Nature Green",
|
||||
primary: "#386A20",
|
||||
secondary: "#55624C",
|
||||
tertiary: "#19686A",
|
||||
},
|
||||
{
|
||||
id: "orange-warm",
|
||||
name: "温暖橙棕",
|
||||
nameEn: "Warm Orange",
|
||||
primary: "#9C4323",
|
||||
secondary: "#77574E",
|
||||
tertiary: "#6C5D2F",
|
||||
},
|
||||
{
|
||||
id: "ocean-breeze",
|
||||
name: "海洋清风",
|
||||
nameEn: "Ocean Breeze",
|
||||
primary: "#0077B6",
|
||||
secondary: "#4A5568",
|
||||
tertiary: "#00B4D8",
|
||||
},
|
||||
{
|
||||
id: "rose-romantic",
|
||||
name: "浪漫玫瑰",
|
||||
nameEn: "Romantic Rose",
|
||||
primary: "#BE185D",
|
||||
secondary: "#9F1239",
|
||||
tertiary: "#DB2777",
|
||||
},
|
||||
];
|
||||
|
||||
// Get stored preset or default to blue-business name
|
||||
const selectedThemePreset = ref(
|
||||
localStorage.getItem("themePreset") || themePresets[0].name,
|
||||
);
|
||||
|
||||
// Simple array for dropdown display
|
||||
const presetOptions = themePresets.map((p) => p.name);
|
||||
|
||||
const applyThemePreset = (presetName: string) => {
|
||||
const preset = themePresets.find((p) => p.name === presetName);
|
||||
if (!preset) return;
|
||||
|
||||
// Store the preset selection (store by name for display consistency)
|
||||
localStorage.setItem("themePreset", presetName);
|
||||
selectedThemePreset.value = presetName;
|
||||
|
||||
// Update primary and secondary colors
|
||||
primaryColor.value = preset.primary;
|
||||
secondaryColor.value = preset.secondary;
|
||||
localStorage.setItem("themePrimary", preset.primary);
|
||||
localStorage.setItem("themeSecondary", preset.secondary);
|
||||
|
||||
// Apply to themes
|
||||
applyThemeColors(preset.primary, preset.secondary);
|
||||
|
||||
toastStore.add({ message: tm("theme.customize.presetApplied") || "主题已应用", color: "success" });
|
||||
};
|
||||
|
||||
const resolveThemes = () => {
|
||||
if (theme?.themes?.value) return theme.themes.value;
|
||||
if (theme?.global?.themes?.value) return theme.global.themes.value;
|
||||
@@ -325,7 +454,7 @@ const resolveThemes = () => {
|
||||
const applyThemeColors = (primary, secondary) => {
|
||||
const themes = resolveThemes();
|
||||
if (!themes) return;
|
||||
["PurpleTheme", "PurpleThemeDark"].forEach((name) => {
|
||||
[LIGHT_THEME_NAME, DARK_THEME_NAME].forEach((name) => {
|
||||
const themeDef = themes[name];
|
||||
if (!themeDef?.colors) return;
|
||||
if (primary) themeDef.colors.primary = primary;
|
||||
@@ -567,8 +696,8 @@ const openBackupDialog = () => {
|
||||
};
|
||||
|
||||
const resetThemeColors = () => {
|
||||
primaryColor.value = PurpleTheme.colors.primary;
|
||||
secondaryColor.value = PurpleTheme.colors.secondary;
|
||||
primaryColor.value = BlueBusinessLightTheme.colors.primary;
|
||||
secondaryColor.value = BlueBusinessLightTheme.colors.secondary;
|
||||
localStorage.removeItem("themePrimary");
|
||||
localStorage.removeItem("themeSecondary");
|
||||
applyThemeColors(primaryColor.value, secondaryColor.value);
|
||||
|
||||
285
tests/checkTranslations.ts
Normal file
285
tests/checkTranslations.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Translation Key Consistency Checker
|
||||
*
|
||||
* This script checks that all translation keys are present in all locale files.
|
||||
* Missing keys are reported with their file paths.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx tests/checkTranslations.ts
|
||||
* npx tsx tests/checkTranslations.ts --verbose
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from "fs/promises";
|
||||
import { join, relative } from "path";
|
||||
|
||||
const LOCALES_DIR = join(__dirname, "../dashboard/src/i18n/locales");
|
||||
const LOCALES = ["en-US", "zh-CN", "ru-RU"];
|
||||
|
||||
interface TranslationMap {
|
||||
[key: string]: string | TranslationMap;
|
||||
}
|
||||
|
||||
interface FileReport {
|
||||
file: string;
|
||||
missingKeys: string[];
|
||||
}
|
||||
|
||||
interface LocaleReport {
|
||||
locale: string;
|
||||
baseMissingKeys: string[];
|
||||
crossLocaleMissing: Map<string, string[]>; // key -> [files missing it]
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract all dot-notation keys from a translation object
|
||||
*/
|
||||
function extractKeys(obj: TranslationMap, prefix = ""): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
// Recurse into nested objects
|
||||
const nestedKeys = extractKeys(value as TranslationMap, fullKey);
|
||||
nestedKeys.forEach((k) => keys.add(k));
|
||||
} else {
|
||||
keys.add(fullKey);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for missing keys between two translation maps
|
||||
*/
|
||||
function findMissingKeys(source: Set<string>, target: Set<string>): string[] {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const key of source) {
|
||||
if (!target.has(key)) {
|
||||
missing.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return missing.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse a translation JSON file
|
||||
*/
|
||||
async function loadTranslationFile(filePath: string): Promise<TranslationMap | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${filePath}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all JSON files in a directory recursively
|
||||
*/
|
||||
async function getJsonFiles(dir: string, baseDir = ""): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nestedFiles = await getJsonFiles(fullPath, relativePath);
|
||||
files.push(...nestedFiles);
|
||||
} else if (entry.name.endsWith(".json")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative path from locales directory
|
||||
* Normalizes the path to compare across locales
|
||||
*/
|
||||
function getRelativePath(filePath: string): string {
|
||||
// Remove locale prefix to get the relative path
|
||||
// e.g., /path/to/locales/en-US/features/test.json -> features/test.json
|
||||
const relativePath = relative(LOCALES_DIR, filePath);
|
||||
const parts = relativePath.split("/");
|
||||
|
||||
// If the first part is a locale name (en-US, zh-CN, ru-RU), remove it
|
||||
if (parts.length > 1 && LOCALES.includes(parts[0])) {
|
||||
return parts.slice(1).join("/");
|
||||
}
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main check function
|
||||
*/
|
||||
async function checkTranslations(verbose = false): Promise<void> {
|
||||
console.log("🔍 Checking translation key consistency...\n");
|
||||
|
||||
// Load base locale (English) first
|
||||
const baseLocale = "en-US";
|
||||
const baseDir = join(LOCALES_DIR, baseLocale);
|
||||
const baseFiles = await getJsonFiles(baseDir);
|
||||
|
||||
console.log(`📁 Base locale: ${baseLocale} (${baseFiles.length} files)\n`);
|
||||
|
||||
// Build base translations map
|
||||
const baseTranslations = new Map<string, Set<string>>(); // file -> keys
|
||||
|
||||
for (const file of baseFiles) {
|
||||
const relativePath = getRelativePath(file);
|
||||
const content = await loadTranslationFile(file);
|
||||
|
||||
if (content) {
|
||||
const keys = extractKeys(content);
|
||||
baseTranslations.set(relativePath, keys);
|
||||
|
||||
if (verbose) {
|
||||
console.log(` ✅ ${relativePath}: ${keys.size} keys`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check each other locale
|
||||
for (const locale of LOCALES) {
|
||||
if (locale === baseLocale) continue;
|
||||
|
||||
console.log(`\n🌐 Checking locale: ${locale}`);
|
||||
|
||||
const localeDir = join(LOCALES_DIR, locale);
|
||||
const localeFiles = await getJsonFiles(localeDir);
|
||||
|
||||
const localeTranslations = new Map<string, Set<string>>();
|
||||
const missingFiles: string[] = [];
|
||||
const crossLocaleMissing = new Map<string, string[]>();
|
||||
|
||||
// Load locale translations
|
||||
for (const file of localeFiles) {
|
||||
const relativePath = getRelativePath(file);
|
||||
const content = await loadTranslationFile(file);
|
||||
|
||||
if (content) {
|
||||
const keys = extractKeys(content);
|
||||
localeTranslations.set(relativePath, keys);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing files in this locale
|
||||
for (const [baseFile] of baseTranslations) {
|
||||
if (!localeTranslations.has(baseFile)) {
|
||||
missingFiles.push(baseFile);
|
||||
if (!crossLocaleMissing.has(baseFile)) {
|
||||
crossLocaleMissing.set(baseFile, []);
|
||||
}
|
||||
crossLocaleMissing.get(baseFile)!.push(`${locale} (missing file)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing keys in each file
|
||||
for (const [baseFile, baseKeys] of baseTranslations) {
|
||||
const localeKeys = localeTranslations.get(baseFile);
|
||||
|
||||
if (!localeKeys) continue; // Already reported as missing file
|
||||
|
||||
const missing = findMissingKeys(baseKeys, localeKeys);
|
||||
|
||||
if (missing.length > 0) {
|
||||
for (const key of missing) {
|
||||
if (!crossLocaleMissing.has(key)) {
|
||||
crossLocaleMissing.set(key, []);
|
||||
}
|
||||
crossLocaleMissing.get(key)!.push(`${locale}: ${baseFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
if (missingFiles.length > 0) {
|
||||
console.log(`\n ⚠️ Missing files in ${locale}:`);
|
||||
for (const file of missingFiles) {
|
||||
console.log(` - ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (crossLocaleMissing.size > 0) {
|
||||
console.log(`\n ⚠️ Missing keys in ${locale}:`);
|
||||
|
||||
let keyCount = 0;
|
||||
for (const [key, locations] of crossLocaleMissing) {
|
||||
if (locations.some((l) => l.includes("missing file"))) continue; // Skip file-level issues
|
||||
keyCount++;
|
||||
console.log(`\n Key: ${key}`);
|
||||
for (const loc of locations) {
|
||||
console.log(` → ${loc}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 📊 Total missing keys in ${locale}: ${crossLocaleMissing.size}`);
|
||||
} else {
|
||||
console.log(` ✅ All keys present in ${locale}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("📋 Summary");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
console.log(`\nBase locale: ${baseLocale}`);
|
||||
console.log(`Locales checked: ${LOCALES.filter((l) => l !== baseLocale).join(", ")}`);
|
||||
console.log(`Files in base: ${baseFiles.length}`);
|
||||
|
||||
// Count total missing across all non-base locales
|
||||
let totalMissingKeys = 0;
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
if (locale === baseLocale) continue;
|
||||
|
||||
const localeDir = join(LOCALES_DIR, locale);
|
||||
const localeFiles = await getJsonFiles(localeDir);
|
||||
const localeTranslations = new Map<string, Set<string>>();
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const relativePath = getRelativePath(file);
|
||||
const content = await loadTranslationFile(file);
|
||||
|
||||
if (content) {
|
||||
const keys = extractKeys(content);
|
||||
localeTranslations.set(relativePath, keys);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [baseFile, baseKeys] of baseTranslations) {
|
||||
const localeKeys = localeTranslations.get(baseFile);
|
||||
if (!localeKeys) continue;
|
||||
|
||||
const missing = findMissingKeys(baseKeys, localeKeys);
|
||||
totalMissingKeys += missing.length;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal missing translation keys: ${totalMissingKeys}`);
|
||||
|
||||
if (totalMissingKeys === 0) {
|
||||
console.log("\n🎉 All translation keys are complete!");
|
||||
} else {
|
||||
console.log("\n⚠️ Please add the missing translation keys to complete the localization.");
|
||||
}
|
||||
|
||||
console.log("\n💡 To run with verbose output: npx tsx tests/checkTranslations.ts --verbose");
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
||||
|
||||
// Run the check
|
||||
checkTranslations(verbose).catch(console.error);
|
||||
105
tests/unit/test_core/test_agent/test_tool_session_manager.py
Normal file
105
tests/unit/test_core/test_agent/test_tool_session_manager.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Tests for ToolSessionManager and ToolSessionState.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.agent.tool_session_manager import (
|
||||
ToolSessionManager,
|
||||
ToolSessionState,
|
||||
)
|
||||
|
||||
|
||||
class TestToolSessionState:
|
||||
def test_get_state_creates_if_not_exists(self):
|
||||
state = ToolSessionState(umo="umo1", tool_name="tool1")
|
||||
assert state.umo == "umo1"
|
||||
assert state.tool_name == "tool1"
|
||||
assert len(state) == 0
|
||||
|
||||
def test_dict_like_behavior(self):
|
||||
state = ToolSessionState(umo="umo1", tool_name="tool1")
|
||||
state["cwd"] = "/tmp"
|
||||
state["env"] = {"PATH": "/usr/bin"}
|
||||
assert state["cwd"] == "/tmp"
|
||||
assert state["env"] == {"PATH": "/usr/bin"}
|
||||
assert len(state) == 2
|
||||
|
||||
def test_persistent_keys(self):
|
||||
state = ToolSessionState(umo="umo1", tool_name="tool1")
|
||||
state["temp"] = "data"
|
||||
state.set_persistent("persistent_data")
|
||||
state["persistent_data"] = "important"
|
||||
assert state.is_persistent("persistent_data") is True
|
||||
assert state.is_persistent("temp") is False
|
||||
|
||||
def test_iter_and_len(self):
|
||||
state = ToolSessionState(umo="umo1", tool_name="tool1")
|
||||
state["a"] = 1
|
||||
state["b"] = 2
|
||||
assert list(state) == ["a", "b"]
|
||||
assert len(state) == 2
|
||||
|
||||
def test_delitem(self):
|
||||
state = ToolSessionState(umo="umo1", tool_name="tool1")
|
||||
state["key"] = "value"
|
||||
del state["key"]
|
||||
assert "key" not in state
|
||||
|
||||
|
||||
class TestToolSessionManager:
|
||||
def test_get_state_creates_if_not_exists(self):
|
||||
mgr = ToolSessionManager()
|
||||
state1 = mgr.get_state("umo1", "tool1")
|
||||
state2 = mgr.get_state("umo1", "tool1")
|
||||
assert state1 is state2 # Same instance
|
||||
|
||||
def test_different_tools_have_different_state(self):
|
||||
mgr = ToolSessionManager()
|
||||
state1 = mgr.get_state("umo1", "tool1")
|
||||
state1["key"] = "value"
|
||||
state2 = mgr.get_state("umo1", "tool2")
|
||||
assert "key" not in state2
|
||||
|
||||
def test_different_sessions_have_different_state(self):
|
||||
mgr = ToolSessionManager()
|
||||
state1 = mgr.get_state("umo1", "tool1")
|
||||
state1["key"] = "value1"
|
||||
state2 = mgr.get_state("umo2", "tool1")
|
||||
state2["key"] = "value2"
|
||||
assert state1["key"] == "value1"
|
||||
assert state2["key"] == "value2"
|
||||
|
||||
def test_clear_session_keeps_persistent(self):
|
||||
mgr = ToolSessionManager()
|
||||
state = mgr.get_state("umo1", "tool1")
|
||||
state["temp"] = "data"
|
||||
state.set_persistent("persistent_data")
|
||||
state["persistent_data"] = "important"
|
||||
|
||||
mgr.clear_session("umo1")
|
||||
|
||||
assert "temp" not in state
|
||||
assert state["persistent_data"] == "important"
|
||||
|
||||
def test_clear_session_only_clears_target_umo(self):
|
||||
mgr = ToolSessionManager()
|
||||
state1 = mgr.get_state("umo1", "tool1")
|
||||
state1["key"] = "value1"
|
||||
state2 = mgr.get_state("umo2", "tool1")
|
||||
state2["key"] = "value2"
|
||||
|
||||
mgr.clear_session("umo1")
|
||||
|
||||
assert "key" not in state1
|
||||
assert state2["key"] == "value2"
|
||||
|
||||
def test_state_persistence_across_clears(self):
|
||||
mgr = ToolSessionManager()
|
||||
state1 = mgr.get_state("umo1", "tool1")
|
||||
state1["key"] = "value1"
|
||||
mgr.clear_session("umo1")
|
||||
# After clear, state is still accessible (just emptied of non-persistent)
|
||||
assert len(state1) == 0
|
||||
state1["key"] = "value1_after"
|
||||
assert mgr.get_state("umo1", "tool1")["key"] == "value1_after"
|
||||
Reference in New Issue
Block a user