Compare commits
11 Commits
v0.3.1
...
c5890bd5f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5890bd5f2 | ||
|
|
96be7c67f3 | ||
|
|
ae1028588b | ||
|
|
dd06a3159b | ||
|
|
f20a9bb851 | ||
|
|
8294c12d17 | ||
|
|
55de02f5e3 | ||
|
|
c1b3c3b142 | ||
|
|
a8e14269af | ||
|
|
f21366e55f | ||
|
|
6ff6fd3f25 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ai-translate-client",
|
"name": "ai-translate-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.1",
|
"version": "0.3.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -19,7 +19,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ai-translate-client"
|
name = "ai-translate-client"
|
||||||
version = "0.3.1"
|
version = "0.3.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ai-translate-client"
|
name = "ai-translate-client"
|
||||||
version = "0.3.1"
|
version = "0.3.3"
|
||||||
description = "A client using AI models to translate"
|
description = "A client using AI models to translate"
|
||||||
authors = ["Julian"]
|
authors = ["Julian"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ai-translate-client",
|
"productName": "ai-translate-client",
|
||||||
"version": "0.3.1",
|
"version": "0.3.3",
|
||||||
"identifier": "top.volan.ai-translate-client",
|
"identifier": "top.volan.ai-translate-client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
430
src/App.vue
430
src/App.vue
@@ -25,6 +25,8 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
LANGUAGES,
|
LANGUAGES,
|
||||||
DEFAULT_TEMPLATE,
|
DEFAULT_TEMPLATE,
|
||||||
|
DEFAULT_EVALUATION_TEMPLATE,
|
||||||
|
DEFAULT_REFINEMENT_TEMPLATE,
|
||||||
SPEAKER_IDENTITY_OPTIONS,
|
SPEAKER_IDENTITY_OPTIONS,
|
||||||
TONE_REGISTER_OPTIONS,
|
TONE_REGISTER_OPTIONS,
|
||||||
type ApiProfile
|
type ApiProfile
|
||||||
@@ -89,20 +91,23 @@ const sourceDropdownOpen = ref(false);
|
|||||||
const targetDropdownOpen = ref(false);
|
const targetDropdownOpen = ref(false);
|
||||||
const speakerDropdownOpen = ref(false);
|
const speakerDropdownOpen = ref(false);
|
||||||
const toneDropdownOpen = ref(false);
|
const toneDropdownOpen = ref(false);
|
||||||
|
const evaluationProfileDropdownOpen = ref(false);
|
||||||
|
|
||||||
const closeAllDropdowns = () => {
|
const closeAllDropdowns = () => {
|
||||||
sourceDropdownOpen.value = false;
|
sourceDropdownOpen.value = false;
|
||||||
targetDropdownOpen.value = false;
|
targetDropdownOpen.value = false;
|
||||||
speakerDropdownOpen.value = false;
|
speakerDropdownOpen.value = false;
|
||||||
toneDropdownOpen.value = false;
|
toneDropdownOpen.value = false;
|
||||||
|
evaluationProfileDropdownOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDropdown = (type: 'source' | 'target' | 'speaker' | 'tone') => {
|
const toggleDropdown = (type: 'source' | 'target' | 'speaker' | 'tone' | 'evaluationProfile') => {
|
||||||
const states = {
|
const states = {
|
||||||
source: sourceDropdownOpen,
|
source: sourceDropdownOpen,
|
||||||
target: targetDropdownOpen,
|
target: targetDropdownOpen,
|
||||||
speaker: speakerDropdownOpen,
|
speaker: speakerDropdownOpen,
|
||||||
tone: toneDropdownOpen
|
tone: toneDropdownOpen,
|
||||||
|
evaluationProfile: evaluationProfileDropdownOpen
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetState = states[type];
|
const targetState = states[type];
|
||||||
@@ -134,11 +139,39 @@ const targetText = ref('');
|
|||||||
const isTranslating = ref(false);
|
const isTranslating = ref(false);
|
||||||
const showCopyFeedback = ref(false);
|
const showCopyFeedback = ref(false);
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
importance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EvaluationResult {
|
||||||
|
score: number;
|
||||||
|
analysis: string;
|
||||||
|
suggestions?: Suggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluationResult = ref<EvaluationResult | null>(null);
|
||||||
|
const isEvaluating = ref(false);
|
||||||
|
const isRefining = ref(false);
|
||||||
|
const selectedSuggestionIds = ref<number[]>([]);
|
||||||
|
const appliedSuggestionIds = ref<number[]>([]);
|
||||||
|
|
||||||
|
const toggleSuggestion = (id: number) => {
|
||||||
|
if (!selectedSuggestionIds.value) selectedSuggestionIds.value = [];
|
||||||
|
const index = selectedSuggestionIds.value.indexOf(id);
|
||||||
|
if (index > -1) {
|
||||||
|
selectedSuggestionIds.value.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
selectedSuggestionIds.value.push(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let unlisten: (() => void) | null = null;
|
let unlisten: (() => void) | null = null;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
unlisten = await listen<string>('translation-chunk', (event) => {
|
unlisten = await listen<string>('translation-chunk', (event) => {
|
||||||
if (isTranslating.value) {
|
if (isTranslating.value || isRefining.value) {
|
||||||
targetText.value += event.payload;
|
targetText.value += event.payload;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -176,6 +209,12 @@ const currentToneLabel = computed(() => {
|
|||||||
return TONE_REGISTER_OPTIONS.find(opt => opt.value === settings.toneRegister)?.label || '正式专业';
|
return TONE_REGISTER_OPTIONS.find(opt => opt.value === settings.toneRegister)?.label || '正式专业';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentEvaluationProfileLabel = computed(() => {
|
||||||
|
if (!settings.evaluationProfileId) return '使用主翻译配置(默认)';
|
||||||
|
const profile = settings.profiles.find(p => p.id === settings.evaluationProfileId);
|
||||||
|
return profile ? `${profile.name} — ${profile.modelName}` : '使用主翻译配置(默认)';
|
||||||
|
});
|
||||||
|
|
||||||
const swapLanguages = () => {
|
const swapLanguages = () => {
|
||||||
const temp = { ...settings.sourceLang };
|
const temp = { ...settings.sourceLang };
|
||||||
settings.sourceLang = { ...settings.targetLang };
|
settings.sourceLang = { ...settings.targetLang };
|
||||||
@@ -185,6 +224,7 @@ const swapLanguages = () => {
|
|||||||
const clearSource = () => {
|
const clearSource = () => {
|
||||||
sourceText.value = '';
|
sourceText.value = '';
|
||||||
targetText.value = '';
|
targetText.value = '';
|
||||||
|
evaluationResult.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyTarget = async () => {
|
const copyTarget = async () => {
|
||||||
@@ -199,11 +239,154 @@ const copyTarget = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const evaluateTranslation = async () => {
|
||||||
|
if (!targetText.value) return;
|
||||||
|
|
||||||
|
isEvaluating.value = true;
|
||||||
|
evaluationResult.value = null;
|
||||||
|
selectedSuggestionIds.value = [];
|
||||||
|
appliedSuggestionIds.value = [];
|
||||||
|
|
||||||
|
// Determine which API config to use for evaluation
|
||||||
|
let apiBaseUrl = settings.apiBaseUrl;
|
||||||
|
let apiKey = settings.apiKey;
|
||||||
|
let modelName = settings.modelName;
|
||||||
|
|
||||||
|
if (settings.evaluationProfileId) {
|
||||||
|
const profile = settings.profiles.find(p => p.id === settings.evaluationProfileId);
|
||||||
|
if (profile) {
|
||||||
|
apiBaseUrl = profile.apiBaseUrl;
|
||||||
|
apiKey = profile.apiKey;
|
||||||
|
modelName = profile.modelName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluationSystemPrompt = settings.evaluationPromptTemplate
|
||||||
|
.replace(/{SOURCE_LANG}/g, sourceLang.value.englishName)
|
||||||
|
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
|
||||||
|
.replace(/{SPEAKER_IDENTITY}/g, settings.speakerIdentity)
|
||||||
|
.replace(/{TONE_REGISTER}/g, settings.toneRegister)
|
||||||
|
.replace(/{CONTEXT}/g, context.value || 'None');
|
||||||
|
|
||||||
|
const evaluationUserPrompt = `[Source Text]\n${sourceText.value}\n\n[Translated Text]\n${targetText.value}`;
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
model: modelName,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: evaluationSystemPrompt },
|
||||||
|
{ role: "user", content: evaluationUserPrompt }
|
||||||
|
],
|
||||||
|
stream: false // Non-streaming for evaluation to parse JSON
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.addLog('request', { type: 'evaluation', ...requestBody });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await invoke<string>('translate', {
|
||||||
|
apiAddress: apiBaseUrl,
|
||||||
|
apiKey: apiKey,
|
||||||
|
payload: requestBody
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to extract JSON if the model wrapped it in code blocks
|
||||||
|
const jsonStr = response.replace(/```json\s?|\s?```/g, '').trim();
|
||||||
|
evaluationResult.value = JSON.parse(jsonStr);
|
||||||
|
settings.addLog('response', { type: 'evaluation', content: evaluationResult.value });
|
||||||
|
} catch (parseErr) {
|
||||||
|
console.error('Failed to parse evaluation result:', response);
|
||||||
|
settings.addLog('error', `Evaluation parsing error: ${response}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
settings.addLog('error', `Evaluation error: ${String(err)}`);
|
||||||
|
} finally {
|
||||||
|
isEvaluating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refineTranslation = async () => {
|
||||||
|
if (!targetText.value || isRefining.value) return;
|
||||||
|
|
||||||
|
const selectedTexts = evaluationResult.value?.suggestions
|
||||||
|
?.filter(s => selectedSuggestionIds.value?.includes(s.id))
|
||||||
|
.map(s => s.text);
|
||||||
|
|
||||||
|
if (!selectedTexts || selectedTexts.length === 0) return;
|
||||||
|
|
||||||
|
isRefining.value = true;
|
||||||
|
const originalTranslation = targetText.value;
|
||||||
|
targetText.value = '';
|
||||||
|
// Keep evaluationResult and selectedSuggestionIds to allow the user to see what was changed
|
||||||
|
|
||||||
|
// Determine which API config to use (same as audit)
|
||||||
|
let apiBaseUrl = settings.apiBaseUrl;
|
||||||
|
let apiKey = settings.apiKey;
|
||||||
|
let modelName = settings.modelName;
|
||||||
|
|
||||||
|
if (settings.evaluationProfileId) {
|
||||||
|
const profile = settings.profiles.find(p => p.id === settings.evaluationProfileId);
|
||||||
|
if (profile) {
|
||||||
|
apiBaseUrl = profile.apiBaseUrl;
|
||||||
|
apiKey = profile.apiKey;
|
||||||
|
modelName = profile.modelName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refinementSystemPrompt = settings.refinementPromptTemplate
|
||||||
|
.replace(/{SOURCE_LANG}/g, sourceLang.value.englishName)
|
||||||
|
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
|
||||||
|
.replace(/{SPEAKER_IDENTITY}/g, settings.speakerIdentity)
|
||||||
|
.replace(/{TONE_REGISTER}/g, settings.toneRegister)
|
||||||
|
.replace(/{CONTEXT}/g, context.value || 'None');
|
||||||
|
|
||||||
|
const formattedSuggestions = selectedTexts.map((text, i) => `${i + 1}. ${text}`).join('\n');
|
||||||
|
|
||||||
|
const refinementUserPrompt = `[Source Text]\n${sourceText.value}\n\n[Current Translation]\n${originalTranslation}\n\n[User Feedback]\n${formattedSuggestions}`;
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
model: modelName,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: refinementSystemPrompt },
|
||||||
|
{ role: "user", content: refinementUserPrompt }
|
||||||
|
],
|
||||||
|
stream: settings.enableStreaming
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.addLog('request', { type: 'refinement', ...requestBody });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await invoke<string>('translate', {
|
||||||
|
apiAddress: apiBaseUrl,
|
||||||
|
apiKey: apiKey,
|
||||||
|
payload: requestBody
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings.enableStreaming) {
|
||||||
|
targetText.value = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track applied suggestions and clear selection
|
||||||
|
if (evaluationResult.value?.suggestions) {
|
||||||
|
appliedSuggestionIds.value.push(...selectedSuggestionIds.value);
|
||||||
|
selectedSuggestionIds.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.addLog('response', 'Refinement completed');
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = String(err);
|
||||||
|
settings.addLog('error', errorMsg);
|
||||||
|
targetText.value = `Error: ${errorMsg}`;
|
||||||
|
} finally {
|
||||||
|
isRefining.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const translate = async () => {
|
const translate = async () => {
|
||||||
if (!sourceText.value.trim() || isTranslating.value) return;
|
if (!sourceText.value.trim() || isTranslating.value) return;
|
||||||
|
|
||||||
isTranslating.value = true;
|
isTranslating.value = true;
|
||||||
targetText.value = '';
|
targetText.value = '';
|
||||||
|
evaluationResult.value = null;
|
||||||
|
|
||||||
const systemMessage = settings.systemPromptTemplate
|
const systemMessage = settings.systemPromptTemplate
|
||||||
.replace(/{SOURCE_LANG}/g, sourceLang.value.englishName)
|
.replace(/{SOURCE_LANG}/g, sourceLang.value.englishName)
|
||||||
@@ -247,6 +430,11 @@ const translate = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
isTranslating.value = false;
|
isTranslating.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger evaluation if enabled
|
||||||
|
if (settings.enableEvaluation) {
|
||||||
|
await evaluateTranslation();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -369,7 +557,7 @@ const translate = async () => {
|
|||||||
|
|
||||||
<div class="p-4 border-t dark:border-slate-800 bg-slate-50/30 dark:bg-transparent flex justify-end shrink-0"> <button
|
<div class="p-4 border-t dark:border-slate-800 bg-slate-50/30 dark:bg-transparent flex justify-end shrink-0"> <button
|
||||||
@click="translate"
|
@click="translate"
|
||||||
:disabled="isTranslating || !sourceText.trim()"
|
:disabled="isTranslating || isEvaluating || isRefining || !sourceText.trim()"
|
||||||
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 dark:disabled:bg-blue-900/40 text-white px-6 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shadow-sm"
|
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 dark:disabled:bg-blue-900/40 text-white px-6 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shadow-sm"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isTranslating" class="w-4 h-4 animate-spin" />
|
<Loader2 v-if="isTranslating" class="w-4 h-4 animate-spin" />
|
||||||
@@ -521,6 +709,119 @@ const translate = async () => {
|
|||||||
</template>
|
</template>
|
||||||
<span v-else class="text-slate-300 dark:text-slate-600 italic">翻译结果将在此显示...</span>
|
<span v-else class="text-slate-300 dark:text-slate-600 italic">翻译结果将在此显示...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Evaluation Results -->
|
||||||
|
<div v-if="isEvaluating || evaluationResult" class="px-6 py-4 bg-slate-200/20 dark:bg-slate-800/20 border-t border-dashed dark:border-slate-800 space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-500 overflow-y-auto max-h-80 shrink-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div :class="cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
isEvaluating ? 'bg-blue-400 animate-pulse' : (evaluationResult?.score && evaluationResult.score >= 80 ? 'bg-green-500' : evaluationResult?.score && evaluationResult.score >= 60 ? 'bg-amber-500' : 'bg-red-500')
|
||||||
|
)"></div>
|
||||||
|
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest">质量审计</h3>
|
||||||
|
</div>
|
||||||
|
<div v-if="evaluationResult" :class="cn(
|
||||||
|
'text-lg font-black font-mono',
|
||||||
|
evaluationResult.score >= 80 ? 'text-green-600' : evaluationResult.score >= 60 ? 'text-amber-600' : 'text-red-600'
|
||||||
|
)">
|
||||||
|
{{ evaluationResult.score }} <span class="text-[10px] font-normal opacity-50">/ 100</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isEvaluating" class="flex items-center gap-1.5 text-xs text-blue-500 font-medium">
|
||||||
|
<Loader2 class="w-3 h-3 animate-spin" />
|
||||||
|
正在审计...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="evaluationResult" class="space-y-3">
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-800/40 p-3 rounded-lg border border-slate-100 dark:border-slate-800/60">
|
||||||
|
<p class="text-xs text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||||
|
{{ evaluationResult.analysis }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="evaluationResult.suggestions && evaluationResult.suggestions.length > 0" class="space-y-4 pt-2">
|
||||||
|
<!-- Pending Suggestions -->
|
||||||
|
<div v-if="evaluationResult.suggestions.some(s => !appliedSuggestionIds.includes(s.id))" class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||||
|
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest">修改建议</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="sug in evaluationResult.suggestions.filter(s => !appliedSuggestionIds.includes(s.id))"
|
||||||
|
:key="sug.id"
|
||||||
|
@click="toggleSuggestion(sug.id)"
|
||||||
|
:class="cn(
|
||||||
|
'flex items-start gap-3 p-3 rounded-xl border transition-all cursor-pointer group',
|
||||||
|
selectedSuggestionIds?.includes(sug.id)
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||||
|
: 'bg-white dark:bg-slate-800/40 border-slate-100 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700'
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<div :class="cn(
|
||||||
|
'w-4 h-4 rounded border mt-0.5 shrink-0 flex items-center justify-center transition-colors',
|
||||||
|
selectedSuggestionIds?.includes(sug.id) ? 'bg-blue-600 border-blue-600' : 'border-slate-300 dark:border-slate-600'
|
||||||
|
)">
|
||||||
|
<Check v-if="selectedSuggestionIds?.includes(sug.id)" class="w-3.5 h-3.5 text-white" stroke-width="4" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 space-y-1.5 min-w-0">
|
||||||
|
<p class="text-xs text-slate-700 dark:text-slate-200 leading-normal">{{ sug.text }}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1 h-1 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all duration-1000"
|
||||||
|
:class="sug.importance >= 80 ? 'bg-red-500' : sug.importance >= 40 ? 'bg-amber-500' : 'bg-blue-500'"
|
||||||
|
:style="{ width: `${sug.importance}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[9px] font-bold opacity-40 uppercase tracking-tighter w-8 shrink-0">{{ sug.importance }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Applied Suggestions -->
|
||||||
|
<div v-if="appliedSuggestionIds.length > 0" class="space-y-2 border-t dark:border-slate-800 pt-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||||
|
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest">已润色</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="sug in evaluationResult.suggestions.filter(s => appliedSuggestionIds.includes(s.id))"
|
||||||
|
:key="'applied-' + sug.id"
|
||||||
|
class="p-3 rounded-xl border border-slate-100 dark:border-slate-800/60 bg-white/30 dark:bg-slate-800/20 opacity-70"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400 leading-normal">{{ sug.text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border-t dark:border-slate-800 bg-slate-50/30 dark:bg-transparent flex justify-end gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
@click="refineTranslation"
|
||||||
|
v-if="evaluationResult && evaluationResult.suggestions && evaluationResult.suggestions.length > 0"
|
||||||
|
:disabled="isRefining || isEvaluating || isTranslating || selectedSuggestionIds.length === 0"
|
||||||
|
class="bg-blue-600 enabled:hover:bg-blue-700 disabled:bg-blue-300 dark:disabled:bg-blue-900/40 text-white px-6 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isRefining" class="w-4 h-4 animate-spin" />
|
||||||
|
<Save v-else class="w-4 h-4" />
|
||||||
|
{{ isRefining ? '正在润色...' : '润色' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="evaluateTranslation"
|
||||||
|
:disabled="isEvaluating || isTranslating || isRefining || !targetText.trim()"
|
||||||
|
class="bg-blue-600 enabled:hover:bg-blue-700 disabled:bg-blue-300 dark:disabled:bg-blue-900/40 text-white px-6 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isEvaluating" class="w-4 h-4 animate-spin" />
|
||||||
|
<Check v-else class="w-4 h-4" />
|
||||||
|
{{ isEvaluating ? '正在审计...' : '审计' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -647,26 +948,139 @@ const translate = async () => {
|
|||||||
)"></div>
|
)"></div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">自动质量审计</label>
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-500">翻译完成后自动评估准确度</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="settings.enableEvaluation = !settings.enableEvaluation"
|
||||||
|
:class="cn(
|
||||||
|
'w-12 h-6 rounded-full transition-colors relative',
|
||||||
|
settings.enableEvaluation ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-700'
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<div :class="cn(
|
||||||
|
'absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform',
|
||||||
|
settings.enableEvaluation ? 'translate-x-6' : 'translate-x-0'
|
||||||
|
)"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 pt-4 border-t border-dashed dark:border-slate-800 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<Settings class="w-3.5 h-3.5 text-blue-500" />
|
||||||
|
<label class="text-sm font-semibold text-slate-700 dark:text-slate-300">审计模型</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Evaluation Profile Dropdown -->
|
||||||
|
<div class="relative lang-dropdown">
|
||||||
|
<button
|
||||||
|
@click.stop="toggleDropdown('evaluationProfile')"
|
||||||
|
class="flex items-center justify-between w-full px-4 py-2.5 border dark:border-slate-700 rounded-xl bg-slate-50/50 dark:bg-slate-800/30 hover:bg-slate-100/50 dark:hover:bg-slate-800/50 transition-all text-sm text-slate-700 dark:text-slate-200 group"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ currentEvaluationProfileLabel }}</span>
|
||||||
|
<ChevronDown :class="cn('w-4 h-4 text-slate-400 transition-transform duration-200 group-hover:text-blue-500', evaluationProfileDropdownOpen && 'rotate-180')" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition duration-100 ease-out"
|
||||||
|
enter-from-class="transform scale-95 opacity-0"
|
||||||
|
enter-to-class="transform scale-100 opacity-100"
|
||||||
|
leave-active-class="transition duration-75 ease-in"
|
||||||
|
leave-from-class="transform scale-100 opacity-100"
|
||||||
|
leave-to-class="transform scale-95 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="evaluationProfileDropdownOpen"
|
||||||
|
class="absolute left-0 mt-2 w-full max-h-60 overflow-y-auto bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 py-2 flex flex-col custom-scrollbar"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="settings.evaluationProfileId = null; evaluationProfileDropdownOpen = false"
|
||||||
|
:class="cn(
|
||||||
|
'px-4 py-2.5 text-sm text-left transition-colors flex items-center justify-between',
|
||||||
|
settings.evaluationProfileId === null ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 font-bold' : 'text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-700/50'
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
使用主翻译配置(默认)
|
||||||
|
<Check v-if="settings.evaluationProfileId === null" class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<div class="h-px bg-slate-100 dark:bg-slate-700 my-1 mx-2"></div>
|
||||||
|
<button
|
||||||
|
v-for="profile in settings.profiles"
|
||||||
|
:key="profile.id"
|
||||||
|
@click="settings.evaluationProfileId = profile.id; evaluationProfileDropdownOpen = false"
|
||||||
|
:class="cn(
|
||||||
|
'px-4 py-2.5 text-sm text-left transition-colors flex items-center justify-between',
|
||||||
|
settings.evaluationProfileId === profile.id ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 font-bold' : 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700/50'
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<span class="truncate">{{ profile.name }}</span>
|
||||||
|
<span class="text-[10px] opacity-60 font-mono">{{ profile.modelName }}</span>
|
||||||
|
</div>
|
||||||
|
<Check v-if="settings.evaluationProfileId === profile.id" class="w-3.5 h-3.5 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-[11px] text-slate-500 dark:text-slate-500 pl-1">
|
||||||
|
提示:建议为审计选择更强大的模型以获得更精准的反馈
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">提示词工程</h2>
|
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">提示词工程</h2>
|
||||||
<div class="bg-slate-200/20 dark:bg-slate-900 rounded-xl shadow-sm/5 border dark:border-slate-800 p-6">
|
<div class="bg-slate-200/20 dark:bg-slate-900 rounded-xl shadow-sm/5 border dark:border-slate-800 p-6 space-y-6">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">系统提示词模板</label>
|
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">翻译系统提示词模板</label>
|
||||||
<button @click="settings.systemPromptTemplate = DEFAULT_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">恢复默认值</button>
|
<button @click="settings.systemPromptTemplate = DEFAULT_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">恢复默认值</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="settings.systemPromptTemplate"
|
v-model="settings.systemPromptTemplate"
|
||||||
rows="9"
|
rows="6"
|
||||||
class="w-full px-4 py-3 border dark:border-slate-700 rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-xs leading-relaxed text-slate-900 dark:text-slate-100"
|
class="w-full px-4 py-3 border dark:border-slate-700 rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-xs leading-relaxed text-slate-900 dark:text-slate-100"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<span v-for="tag in ['{SOURCE_LANG}', '{SOURCE_CODE}', '{TARGET_LANG}', '{TARGET_CODE}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}']" :key="tag" class="px-2 py-1 bg-slate-100 dark:bg-slate-800 text-[10px] font-mono rounded border dark:border-slate-700 text-slate-600 dark:text-slate-400">{{ tag }}</span>
|
<span v-for="tag in ['{SOURCE_CODE}', '{TARGET_CODE}', '{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}']" :key="tag" class="px-2 py-1 bg-slate-100 dark:bg-slate-800 text-[10px] font-mono rounded border dark:border-slate-700 text-slate-600 dark:text-slate-400">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 border-t dark:border-slate-800 pt-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">审计系统提示词模板</label>
|
||||||
|
<button @click="settings.evaluationPromptTemplate = DEFAULT_EVALUATION_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">恢复默认值</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="settings.evaluationPromptTemplate"
|
||||||
|
rows="8"
|
||||||
|
class="w-full px-4 py-3 border dark:border-slate-700 rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-xs leading-relaxed text-slate-900 dark:text-slate-100"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
|
<span v-for="tag in ['{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}', '{CONTEXT}']" :key="tag" class="px-2 py-1 bg-slate-100 dark:bg-slate-800 text-[10px] font-mono rounded border dark:border-slate-700 text-slate-600 dark:text-slate-400">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 border-t dark:border-slate-800 pt-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">润色系统提示词模板</label>
|
||||||
|
<button @click="settings.refinementPromptTemplate = DEFAULT_REFINEMENT_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">恢复默认值</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="settings.refinementPromptTemplate"
|
||||||
|
rows="8"
|
||||||
|
class="w-full px-4 py-3 border dark:border-slate-700 rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all font-mono text-xs leading-relaxed text-slate-900 dark:text-slate-100"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
|
<span v-for="tag in ['{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}', '{CONTEXT}']" :key="tag" class="px-2 py-1 bg-slate-100 dark:bg-slate-800 text-[10px] font-mono rounded border dark:border-slate-700 text-slate-600 dark:text-slate-400">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
|
||||||
@@ -51,6 +51,60 @@ export const DEFAULT_TEMPLATE = `You are a professional {SOURCE_LANG} ({SOURCE_C
|
|||||||
3. Produce ONLY the {TARGET_LANG} translation, without any additional explanations, notes, or commentary.
|
3. Produce ONLY the {TARGET_LANG} translation, without any additional explanations, notes, or commentary.
|
||||||
4. If [Context] is provided, use it strictly to disambiguate polysemous words. DO NOT add any factual information or descriptive details from the [Context] that are not present in the [Text to Translate].`;
|
4. If [Context] is provided, use it strictly to disambiguate polysemous words. DO NOT add any factual information or descriptive details from the [Context] that are not present in the [Text to Translate].`;
|
||||||
|
|
||||||
|
export const DEFAULT_EVALUATION_TEMPLATE = `# Role
|
||||||
|
You are an **Objective Translation Auditor**. Your task is to evaluate translation quality based on accuracy, grammar, and fundamental readability. Avoid pedantic nitpicking over synonyms, but do point out issues that hinder professional quality.
|
||||||
|
|
||||||
|
# Context Info
|
||||||
|
- **Source Language**: {SOURCE_LANG}
|
||||||
|
- **Target Language**: {TARGET_LANG}
|
||||||
|
- **Speaker Identity**: {SPEAKER_IDENTITY}
|
||||||
|
- **Intended Tone/Register**: {TONE_REGISTER}
|
||||||
|
- **Context**: {CONTEXT}
|
||||||
|
|
||||||
|
# Audit Criteria
|
||||||
|
Only penalize and provide improvements if the translation meets one of these criteria:
|
||||||
|
1. **Semantic Error**: Objective misalignment with the source meaning or complete hallucinations.
|
||||||
|
2. **Grammatical Error**: Clear violations of target language grammar or syntax rules.
|
||||||
|
3. **Tone Failure**: A tone that is the opposite or significantly different from the [Intended Tone/Register].
|
||||||
|
4. **Poor Readability**: The phrasing is so stiff, unnatural, or convoluted that it hinders smooth comprehension (e.g., obvious "translationese" or broken logic).
|
||||||
|
|
||||||
|
**Note**: Do NOT penalize if the translation is simply "not the most elegant" or if there's a subjective preference for a different synonym. If it's natural enough for a native speaker to understand without effort, it's acceptable.
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
1. **Evaluation**: Compare the [Source Text] and [Translated Text] based on the [Audit Criteria].
|
||||||
|
2. **Scoring Strategy**:
|
||||||
|
- **90-100**: Accurate, grammatically sound, and flows naturally.
|
||||||
|
- **75-89**: Accurate meaning, but suffers from "stiff" phrasing or minor flow issues that need adjustment.
|
||||||
|
- **Below 75**: Contains semantic errors, severe grammar issues, or tone mismatches.
|
||||||
|
3. **Analysis**: Provide a concise explanation in Simplified Chinese. Focus on *why* the error matters (e.g., "meaning is reversed" or "too awkward to read").
|
||||||
|
4. **Suggestions**: Provide a list of specific, actionable suggestions. For each, assign an "importance" from 0 to 100 (0 = unnecessary/optional, 100 = critical error).
|
||||||
|
|
||||||
|
# Output Format
|
||||||
|
Respond ONLY in JSON format. The "analysis" and "text" within "suggestions" MUST be in Simplified Chinese:
|
||||||
|
{
|
||||||
|
"score": number,
|
||||||
|
"analysis": "string",
|
||||||
|
"suggestions": [
|
||||||
|
{ "id": 1, "text": "suggestion text", "importance": number }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
export const DEFAULT_REFINEMENT_TEMPLATE = `You are a senior translation editor. Your task is to refine the [Current Translation] based on specific [User Feedback], while strictly maintaining the original meaning of the [Source Text] and adhering to the established context.
|
||||||
|
|
||||||
|
[Context Info]
|
||||||
|
- Source Language: {SOURCE_LANG}
|
||||||
|
- Target Language: {TARGET_LANG}
|
||||||
|
- Speaker Identity: {SPEAKER_IDENTITY}
|
||||||
|
- Intended Tone/Register: {TONE_REGISTER}
|
||||||
|
- Context: {CONTEXT}
|
||||||
|
|
||||||
|
[Instructions]
|
||||||
|
1. Carefully review the [User Feedback] and apply the requested improvements to the [Current Translation].
|
||||||
|
2. Ensure that the refined translation remains semantically identical to the [Source Text].
|
||||||
|
3. Maintain the [Speaker Identity] and [Intended Tone/Register] as specified.
|
||||||
|
4. If a piece of feedback contradicts the [Source Text], prioritize accuracy and provide a balanced refinement.
|
||||||
|
5. Produce ONLY the refined {TARGET_LANG} translation, without any additional explanations, notes, or commentary.`;
|
||||||
|
|
||||||
export interface ApiProfile {
|
export interface ApiProfile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -68,12 +122,28 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const enableStreaming = useLocalStorage('enable-streaming', true);
|
const enableStreaming = useLocalStorage('enable-streaming', true);
|
||||||
const systemPromptTemplate = useLocalStorage('system-prompt-template', DEFAULT_TEMPLATE);
|
const systemPromptTemplate = useLocalStorage('system-prompt-template', DEFAULT_TEMPLATE);
|
||||||
|
|
||||||
|
const enableEvaluation = useLocalStorage('enable-evaluation', true);
|
||||||
|
const evaluationPromptTemplate = useLocalStorage('evaluation-prompt-template', DEFAULT_EVALUATION_TEMPLATE);
|
||||||
|
const evaluationProfileId = useLocalStorage<string | null>('evaluation-profile-id', null);
|
||||||
|
const refinementPromptTemplate = useLocalStorage('refinement-prompt-template', DEFAULT_REFINEMENT_TEMPLATE);
|
||||||
|
|
||||||
// 存储整个对象以保持一致性
|
// 存储整个对象以保持一致性
|
||||||
const sourceLang = useLocalStorage<Language>('source-lang-v2', LANGUAGES[0]);
|
const sourceLang = useLocalStorage<Language>('source-lang-v2', LANGUAGES[0]);
|
||||||
const targetLang = useLocalStorage<Language>('target-lang-v2', LANGUAGES[4]);
|
const targetLang = useLocalStorage<Language>('target-lang-v2', LANGUAGES[4]);
|
||||||
|
|
||||||
const speakerIdentity = useLocalStorage('speaker-identity', SPEAKER_IDENTITY_OPTIONS[0].value);
|
// 按源语言分别存储身份和语气,实现基于语言自动切换
|
||||||
const toneRegister = useLocalStorage('tone-register', TONE_REGISTER_OPTIONS[2].value);
|
const speakerIdentityMap = useLocalStorage<Record<string, string>>('speaker-identity-map', {});
|
||||||
|
const toneRegisterMap = useLocalStorage<Record<string, string>>('tone-register-map', {});
|
||||||
|
|
||||||
|
const speakerIdentity = computed({
|
||||||
|
get: () => speakerIdentityMap.value[sourceLang.value.code] || SPEAKER_IDENTITY_OPTIONS[0].value,
|
||||||
|
set: (val) => { speakerIdentityMap.value[sourceLang.value.code] = val; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const toneRegister = computed({
|
||||||
|
get: () => toneRegisterMap.value[sourceLang.value.code] || TONE_REGISTER_OPTIONS[2].value,
|
||||||
|
set: (val) => { toneRegisterMap.value[sourceLang.value.code] = val; }
|
||||||
|
});
|
||||||
|
|
||||||
const logs = ref<{ timestamp: string; type: 'request' | 'response' | 'error'; content: any }[]>([]);
|
const logs = ref<{ timestamp: string; type: 'request' | 'response' | 'error'; content: any }[]>([]);
|
||||||
|
|
||||||
@@ -97,6 +167,10 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
profiles,
|
profiles,
|
||||||
enableStreaming,
|
enableStreaming,
|
||||||
systemPromptTemplate,
|
systemPromptTemplate,
|
||||||
|
enableEvaluation,
|
||||||
|
evaluationPromptTemplate,
|
||||||
|
evaluationProfileId,
|
||||||
|
refinementPromptTemplate,
|
||||||
sourceLang,
|
sourceLang,
|
||||||
targetLang,
|
targetLang,
|
||||||
speakerIdentity,
|
speakerIdentity,
|
||||||
|
|||||||
Reference in New Issue
Block a user