Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352047c123 | ||
|
|
e0dd17bfff | ||
|
|
c5890bd5f2 | ||
|
|
96be7c67f3 | ||
|
|
ae1028588b | ||
|
|
dd06a3159b | ||
|
|
f20a9bb851 | ||
|
|
8294c12d17 | ||
|
|
55de02f5e3 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ai-translate-client",
|
||||
"private": true,
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -19,7 +19,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-translate-client"
|
||||
version = "0.3.2"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"reqwest 0.12.28",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ai-translate-client"
|
||||
version = "0.3.2"
|
||||
version = "0.3.4"
|
||||
description = "A client using AI models to translate"
|
||||
authors = ["Julian"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ai-translate-client",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.4",
|
||||
"identifier": "top.volan.ai-translate-client",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
494
src/App.vue
494
src/App.vue
@@ -17,7 +17,9 @@ import {
|
||||
Type,
|
||||
Plus,
|
||||
Save,
|
||||
Play
|
||||
Play,
|
||||
Clock,
|
||||
Search
|
||||
} from 'lucide-vue-next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
LANGUAGES,
|
||||
DEFAULT_TEMPLATE,
|
||||
DEFAULT_EVALUATION_TEMPLATE,
|
||||
DEFAULT_REFINEMENT_TEMPLATE,
|
||||
SPEAKER_IDENTITY_OPTIONS,
|
||||
TONE_REGISTER_OPTIONS,
|
||||
type ApiProfile
|
||||
@@ -53,7 +56,45 @@ const toggleTheme = () => {
|
||||
settings.isDark = !settings.isDark;
|
||||
};
|
||||
|
||||
const view = ref<'translate' | 'settings' | 'logs'>('translate');
|
||||
const view = ref<'translate' | 'settings' | 'logs' | 'history'>('translate');
|
||||
|
||||
// History Management
|
||||
const searchQuery = ref('');
|
||||
const selectedHistoryId = ref<string | null>(null);
|
||||
|
||||
const filteredHistory = computed(() => {
|
||||
if (!searchQuery.value.trim()) return settings.history;
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
return settings.history.filter(h =>
|
||||
h.sourceText.toLowerCase().includes(q) ||
|
||||
h.targetText.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const selectedHistoryItem = computed(() =>
|
||||
settings.history.find(h => h.id === selectedHistoryId.value) || null
|
||||
);
|
||||
|
||||
watch(filteredHistory, (newVal) => {
|
||||
if (newVal.length > 0 && !selectedHistoryId.value) {
|
||||
selectedHistoryId.value = newVal[0].id;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const deleteHistoryItem = (id: string) => {
|
||||
settings.history = settings.history.filter(h => h.id !== id);
|
||||
if (selectedHistoryId.value === id) {
|
||||
selectedHistoryId.value = filteredHistory.value[0]?.id || null;
|
||||
}
|
||||
};
|
||||
|
||||
const copyHistoryText = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy history text: ', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Profile Management
|
||||
const newProfileName = ref('');
|
||||
@@ -138,20 +179,39 @@ const targetText = ref('');
|
||||
const isTranslating = ref(false);
|
||||
const showCopyFeedback = ref(false);
|
||||
|
||||
interface Suggestion {
|
||||
id: number;
|
||||
text: string;
|
||||
importance: number;
|
||||
}
|
||||
|
||||
interface EvaluationResult {
|
||||
score: number;
|
||||
analysis: string;
|
||||
improvements?: 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;
|
||||
|
||||
onMounted(async () => {
|
||||
unlisten = await listen<string>('translation-chunk', (event) => {
|
||||
if (isTranslating.value) {
|
||||
if (isTranslating.value || isRefining.value) {
|
||||
targetText.value += event.payload;
|
||||
}
|
||||
});
|
||||
@@ -224,6 +284,8 @@ const evaluateTranslation = async () => {
|
||||
|
||||
isEvaluating.value = true;
|
||||
evaluationResult.value = null;
|
||||
selectedSuggestionIds.value = [];
|
||||
appliedSuggestionIds.value = [];
|
||||
|
||||
// Determine which API config to use for evaluation
|
||||
let apiBaseUrl = settings.apiBaseUrl;
|
||||
@@ -239,20 +301,20 @@ const evaluateTranslation = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const evaluationPrompt = settings.evaluationPromptTemplate
|
||||
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')
|
||||
.replace(/{SOURCE_TEXT}/g, sourceText.value)
|
||||
.replace(/{TRANSLATED_TEXT}/g, targetText.value);
|
||||
.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: "You are a professional translation auditor. You must respond in valid JSON format." },
|
||||
{ role: "user", content: evaluationPrompt }
|
||||
{ role: "system", content: evaluationSystemPrompt },
|
||||
{ role: "user", content: evaluationUserPrompt }
|
||||
],
|
||||
stream: false // Non-streaming for evaluation to parse JSON
|
||||
};
|
||||
@@ -282,6 +344,83 @@ const evaluateTranslation = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
if (!sourceText.value.trim() || isTranslating.value) return;
|
||||
|
||||
@@ -325,10 +464,17 @@ const translate = async () => {
|
||||
}
|
||||
settings.addLog('response', 'Translation completed');
|
||||
|
||||
// Trigger evaluation if enabled
|
||||
if (settings.enableEvaluation) {
|
||||
await evaluateTranslation();
|
||||
}
|
||||
// Save to history
|
||||
settings.addHistory({
|
||||
sourceLang: { ...sourceLang.value },
|
||||
targetLang: { ...targetLang.value },
|
||||
sourceText: sourceText.value,
|
||||
targetText: settings.enableStreaming ? targetText.value : response,
|
||||
context: context.value,
|
||||
speakerIdentity: settings.speakerIdentity,
|
||||
toneRegister: settings.toneRegister,
|
||||
modelName: settings.modelName
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorMsg = String(err);
|
||||
settings.addLog('error', errorMsg);
|
||||
@@ -336,6 +482,11 @@ const translate = async () => {
|
||||
} finally {
|
||||
isTranslating.value = false;
|
||||
}
|
||||
|
||||
// Trigger evaluation if enabled
|
||||
if (settings.enableEvaluation) {
|
||||
await evaluateTranslation();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -370,6 +521,13 @@ const translate = async () => {
|
||||
>
|
||||
<FileText class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
@click="view = 'history'"
|
||||
:class="cn('p-2 rounded-full transition-colors', view === 'history' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'hover:bg-slate-200/50 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300')"
|
||||
title="历史记录"
|
||||
>
|
||||
<Clock class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -458,7 +616,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
|
||||
@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"
|
||||
>
|
||||
<Loader2 v-if="isTranslating" class="w-4 h-4 animate-spin" />
|
||||
@@ -609,56 +767,114 @@ const translate = async () => {
|
||||
{{ targetText }}
|
||||
</template>
|
||||
<span v-else class="text-slate-300 dark:text-slate-600 italic">翻译结果将在此显示...</span>
|
||||
</div>
|
||||
|
||||
<!-- Evaluation Results -->
|
||||
<div v-if="isEvaluating || evaluationResult" class="mt-8 pt-6 border-t dark:border-slate-800 space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<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>
|
||||
<!-- 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.improvements" class="space-y-2 pt-2">
|
||||
<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>
|
||||
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-widest">修改建议</h3>
|
||||
</div>
|
||||
<div class="bg-blue-50/50 dark:bg-blue-900/10 p-3 rounded-lg border border-blue-100/50 dark:border-blue-900/20">
|
||||
<p class="text-xs text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||
{{ evaluationResult.improvements }}
|
||||
</p>
|
||||
<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 shrink-0">
|
||||
<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 || !targetText.trim()"
|
||||
class="bg-slate-200 hover:bg-slate-300 dark:bg-slate-800 dark:hover:bg-slate-700 disabled:opacity-50 text-slate-700 dark:text-slate-200 px-6 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shadow-sm border border-slate-300/50 dark:border-slate-700/50"
|
||||
: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" />
|
||||
@@ -668,6 +884,161 @@ const translate = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History View -->
|
||||
<div v-else-if="view === 'history'" class="flex-1 flex overflow-hidden bg-slate-100/50 dark:bg-slate-950">
|
||||
<!-- History List (Master) -->
|
||||
<div class="w-80 md:w-96 border-r dark:border-slate-800 flex flex-col bg-white/60 dark:bg-slate-900/40">
|
||||
<div class="p-4 border-b dark:border-slate-800 space-y-3">
|
||||
<div class="relative group">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索历史记录..."
|
||||
class="w-full pl-9 pr-4 py-2 bg-slate-100 dark:bg-slate-800 border-none rounded-lg text-sm outline-none focus:ring-2 focus:ring-blue-500/20 transition-all dark:text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-[11px] font-bold text-slate-400 uppercase tracking-wider">共 {{ filteredHistory.length }} 条记录</span>
|
||||
<button
|
||||
@click="settings.history = []"
|
||||
class="text-[11px] text-red-500 hover:underline font-medium"
|
||||
>清空全部</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
||||
<div v-if="filteredHistory.length === 0" class="py-20 text-center space-y-2">
|
||||
<Clock class="w-10 h-10 text-slate-200 dark:text-slate-800 mx-auto" />
|
||||
<p class="text-sm text-slate-400 italic">暂无相关历史</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in filteredHistory"
|
||||
:key="item.id"
|
||||
@click="selectedHistoryId = item.id"
|
||||
:class="cn(
|
||||
'w-full p-4 rounded-xl text-left transition-all border group relative cursor-pointer',
|
||||
selectedHistoryId === item.id
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-900/50 shadow-sm'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50 border-transparent'
|
||||
)"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 uppercase">{{ item.sourceLang.code }}</span>
|
||||
<ArrowRightLeft class="w-3 h-3 text-slate-300" />
|
||||
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400 uppercase">{{ item.targetLang.code }}</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-slate-400 font-mono">{{ item.timestamp.split(' ')[1].substring(0, 5) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-200 font-medium line-clamp-2 leading-relaxed mb-1">{{ item.sourceText }}</p>
|
||||
<p class="text-[11px] text-slate-400 dark:text-slate-500 line-clamp-1 italic">{{ item.targetText }}</p>
|
||||
|
||||
<button
|
||||
@click.stop="deleteHistoryItem(item.id)"
|
||||
class="absolute right-2 bottom-2 p-1.5 rounded-md text-red-400 opacity-0 group-hover:opacity-100 hover:bg-red-50 dark:hover:bg-red-900/30 transition-all"
|
||||
title="删除此记录"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Detail (Detail) -->
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-white dark:bg-slate-900">
|
||||
<template v-if="selectedHistoryItem">
|
||||
<div class="p-6 border-b dark:border-slate-800 flex items-center justify-between shrink-0 bg-slate-50/50 dark:bg-slate-800/20">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100">翻译详情</h2>
|
||||
<span class="text-[11px] font-mono text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded">{{ selectedHistoryItem.timestamp }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-500">
|
||||
<div class="flex items-center gap-1">
|
||||
<User class="w-3 h-3" />
|
||||
{{ SPEAKER_IDENTITY_OPTIONS.find(o => o.value === (selectedHistoryItem?.speakerIdentity))?.label || selectedHistoryItem?.speakerIdentity }}
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Type class="w-3 h-3" />
|
||||
{{ TONE_REGISTER_OPTIONS.find(o => o.value === (selectedHistoryItem?.toneRegister))?.label || selectedHistoryItem?.toneRegister }}
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div class="font-mono text-[10px]">{{ selectedHistoryItem.modelName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="deleteHistoryItem(selectedHistoryItem.id)"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
删除记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8 space-y-10 custom-scrollbar">
|
||||
<!-- Source -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1.5 h-4 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
|
||||
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">{{ selectedHistoryItem.sourceLang.displayName }} (原文)</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="copyHistoryText(selectedHistoryItem.sourceText)"
|
||||
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg text-slate-400 transition-colors"
|
||||
>
|
||||
<Copy class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-800/30 p-6 rounded-2xl border border-slate-100 dark:border-slate-800 text-lg leading-relaxed text-slate-700 dark:text-slate-200 whitespace-pre-wrap">
|
||||
{{ selectedHistoryItem.sourceText }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Context if exists -->
|
||||
<section v-if="selectedHistoryItem.context" class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<FileText class="w-4 h-4 text-slate-300" />
|
||||
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest">情景背景</h3>
|
||||
</div>
|
||||
<div class="bg-amber-50/30 dark:bg-amber-900/10 p-4 rounded-xl border border-amber-100/50 dark:border-amber-900/20 text-sm italic text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
{{ selectedHistoryItem.context }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Target -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1.5 h-4 bg-blue-500 rounded-full"></div>
|
||||
<h3 class="text-xs font-black text-blue-500/60 uppercase tracking-widest">{{ selectedHistoryItem.targetLang.displayName }} (译文)</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="copyHistoryText(selectedHistoryItem.targetText)"
|
||||
class="p-2 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 rounded-lg text-blue-600 dark:text-blue-400 transition-colors"
|
||||
>
|
||||
<Copy class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-blue-50/20 dark:bg-blue-900/10 p-6 rounded-2xl border border-blue-100/50 dark:border-blue-900/20 text-xl leading-relaxed font-medium text-slate-800 dark:text-slate-100 whitespace-pre-wrap shadow-sm">
|
||||
{{ selectedHistoryItem.targetText }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex-1 flex flex-col items-center justify-center text-slate-300 dark:text-slate-800 p-10 text-center space-y-4">
|
||||
<div class="w-20 h-20 rounded-full bg-slate-50 dark:bg-slate-800/50 flex items-center justify-center">
|
||||
<Clock class="w-10 h-10 opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-medium">请从左侧选择一条历史记录查看详情</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings View -->
|
||||
<div v-else-if="view === 'settings'" class="flex-1 overflow-y-auto bg-slate-100/50 dark:bg-slate-950 p-6 md:p-10 min-h-0">
|
||||
<div class="max-w-2xl mx-auto space-y-8">
|
||||
@@ -881,7 +1252,7 @@ const translate = async () => {
|
||||
<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="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>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -896,7 +1267,7 @@ const translate = async () => {
|
||||
|
||||
<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>
|
||||
<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
|
||||
@@ -905,7 +1276,22 @@ const translate = async () => {
|
||||
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}', '{SOURCE_TEXT}', '{TRANSLATED_TEXT}']" :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_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>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
@@ -33,6 +33,7 @@ export const SPEAKER_IDENTITY_OPTIONS = [
|
||||
];
|
||||
|
||||
export const TONE_REGISTER_OPTIONS = [
|
||||
{ label: '自动识别', value: 'Auto-detect', description: '分析并保持原文的语气、情绪和礼貌程度' },
|
||||
{ label: '正式专业', value: 'Formal & Professional', description: '商务邮件、法律合同、官方报告' },
|
||||
{ label: '礼貌客气', value: 'Polite & Respectful', description: '与长辈、客户或初次见面的人交流' },
|
||||
{ label: '礼貌随和', value: 'Polite & Conversational', description: '得体但不刻板的日常对话' },
|
||||
@@ -47,12 +48,49 @@ export const DEFAULT_TEMPLATE = `You are a professional {SOURCE_LANG} ({SOURCE_C
|
||||
|
||||
[Constraints]
|
||||
1. Speaker Identity: {SPEAKER_IDENTITY}. Ensure all grammatical agreements and self-referential terms in {TARGET_LANG} reflect this.
|
||||
2. Tone & Register: {TONE_REGISTER}.
|
||||
2. Tone & Register: {TONE_REGISTER}. (If set to 'Auto-detect', analyze the tone, formality, and emotional nuance of the source text and faithfully replicate it. Do not neutralize strong emotions or unique styles.)
|
||||
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].`;
|
||||
|
||||
export const DEFAULT_EVALUATION_TEMPLATE = `You are an expert translation auditor proficient in {SOURCE_LANG} and {TARGET_LANG}.
|
||||
Your task is to critically evaluate the accuracy and quality of a translation.
|
||||
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}
|
||||
@@ -61,26 +99,12 @@ Your task is to critically evaluate the accuracy and quality of a translation.
|
||||
- Intended Tone/Register: {TONE_REGISTER}
|
||||
- Context: {CONTEXT}
|
||||
|
||||
[Input]
|
||||
- Source Text: {SOURCE_TEXT}
|
||||
- Translated Text: {TRANSLATED_TEXT}
|
||||
|
||||
[Instructions]
|
||||
1. Compare the [Source Text] and [Translated Text] meticulously.
|
||||
2. Check if the translation respects the [Context Info].
|
||||
3. Assign an "Accuracy Score" from 0 to 100.
|
||||
- Give 0 if there are fatal semantic errors, complete hallucinations, or if the meaning is reversed.
|
||||
- Deduct points for minor inaccuracies, unnatural phrasing, or tone mismatches.
|
||||
4. Provide a concise "Analysis" of why you gave that score.
|
||||
5. (Optional) Provide "Improvements" for a more accurate/natural translation.
|
||||
|
||||
[Output Format]
|
||||
You MUST respond in JSON format with the following keys. The values for "analysis" and "improvements" MUST be written in Simplified Chinese (简体中文), except when quoting the source or target text:
|
||||
{
|
||||
"score": number,
|
||||
"analysis": "string",
|
||||
"improvements": "string"
|
||||
}`;
|
||||
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 {
|
||||
id: string;
|
||||
@@ -90,6 +114,19 @@ export interface ApiProfile {
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
sourceLang: Language;
|
||||
targetLang: Language;
|
||||
sourceText: string;
|
||||
targetText: string;
|
||||
context: string;
|
||||
speakerIdentity: string;
|
||||
toneRegister: string;
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const isDark = useLocalStorage('is-dark', false);
|
||||
const apiBaseUrl = useLocalStorage('api-base-url', 'http://localhost:11434/v1');
|
||||
@@ -102,15 +139,28 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
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 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[0].value,
|
||||
set: (val) => { toneRegisterMap.value[sourceLang.value.code] = val; }
|
||||
});
|
||||
|
||||
const logs = ref<{ timestamp: string; type: 'request' | 'response' | 'error'; content: any }[]>([]);
|
||||
const history = useLocalStorage<HistoryItem[]>('translation-history-v1', []);
|
||||
|
||||
const addLog = (type: 'request' | 'response' | 'error', content: any) => {
|
||||
const now = new Date();
|
||||
@@ -124,6 +174,22 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
if (logs.value.length > 20) logs.value.pop();
|
||||
};
|
||||
|
||||
const addHistory = (item: Omit<HistoryItem, 'id' | 'timestamp'>) => {
|
||||
const now = new Date();
|
||||
const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
history.value.unshift({
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp
|
||||
});
|
||||
|
||||
// 限制 100 条
|
||||
if (history.value.length > 100) {
|
||||
history.value = history.value.slice(0, 100);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDark,
|
||||
apiBaseUrl,
|
||||
@@ -135,11 +201,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
enableEvaluation,
|
||||
evaluationPromptTemplate,
|
||||
evaluationProfileId,
|
||||
refinementPromptTemplate,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
speakerIdentity,
|
||||
toneRegister,
|
||||
logs,
|
||||
addLog
|
||||
history,
|
||||
addLog,
|
||||
addHistory
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user