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:
LIghtJUNction
2026-03-29 15:40:40 +08:00
parent 610370f3e8
commit 5ea790d045
9 changed files with 560 additions and 34 deletions

View File

@@ -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)

View File

@@ -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";
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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",

View File

@@ -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,

View File

@@ -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
View 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);

View 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"