support refinement

This commit is contained in:
Julian Freeman
2026-02-25 13:22:39 -04:00
parent f20a9bb851
commit dd06a3159b
2 changed files with 177 additions and 14 deletions

View File

@@ -26,6 +26,7 @@ import {
LANGUAGES,
DEFAULT_TEMPLATE,
DEFAULT_EVALUATION_TEMPLATE,
DEFAULT_REFINEMENT_TEMPLATE,
SPEAKER_IDENTITY_OPTIONS,
TONE_REGISTER_OPTIONS,
type ApiProfile
@@ -138,20 +139,38 @@ 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 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 +243,7 @@ const evaluateTranslation = async () => {
isEvaluating.value = true;
evaluationResult.value = null;
selectedSuggestionIds.value = [];
// Determine which API config to use for evaluation
let apiBaseUrl = settings.apiBaseUrl;
@@ -282,6 +302,76 @@ 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;
}
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;
@@ -458,7 +548,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 || isEvaluating || !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" />
@@ -640,24 +730,62 @@ const translate = async () => {
</p>
</div>
<div v-if="evaluationResult.improvements" class="space-y-2 pt-2">
<div v-if="evaluationResult.suggestions && evaluationResult.suggestions.length > 0" class="space-y-2 pt-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"
: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>
</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()"
:disabled="isEvaluating || isTranslating || isRefining || !targetText.trim()"
class="bg-slate-200 enabled:hover:bg-slate-300 dark:bg-slate-800 dark:enabled: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"
>
<Loader2 v-if="isEvaluating" class="w-4 h-4 animate-spin" />
@@ -909,6 +1037,21 @@ const translate = async () => {
</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>
</section>
</div>