add identity and tone

This commit is contained in:
Julian Freeman
2026-02-23 17:10:46 -04:00
parent fb98ab5472
commit 2c61c19093
3 changed files with 153 additions and 96 deletions

View File

@@ -1,85 +0,0 @@
# PRD: GemmaTrans Desktop Client
## 1. Project Overview
**GemmaTrans** is a lightweight, high-performance desktop translation client built with **Tauri** and **Vue 3**. It is designed to act as a modern frontend for the **TranslateGemma** model running on a local or remote **Ollama** instance (including over private networks like Tailscale). The application prioritizes speed, privacy, and a professional "Google Translate" aesthetic.
---
## 2. Technical Stack
* **Core Framework:** [Tauri](https://tauri.app/) (Rust backend for system-level API access).
* **Frontend Framework:** [Vue 3](https://vuejs.org/) (Composition API).
* **Styling:** [Tailwind CSS](https://tailwindcss.com/) (Minimalist, modern utility-first CSS).
* **Package Manager:** `pnpm`.
* **State Management:** [Pinia](https://pinia.vuejs.org/) (To persist user settings and prompt templates).
* **Icons:** [Lucide Vue Next](https://lucide.dev/).
---
## 3. User Interface (UI) Requirements
The UI should be **Modern, Minimalist, and Functional**, mimicking the clean layout of Google Translate.
### 3.1 Main Translation Interface
* **Split-Pane Layout:**
* **Left (Source):** Input text area with a header for language selection.
* **Right (Target):** Read-only text area for the translation result, with a header for the target language selection.
* **Language Selection:** Dropdowns supporting both **Full Name** (e.g., English) and **ISO Code** (e.g., en).
* **Actions:** * "Translate" button (with loading state).
* "Copy" button on the target pane.
* "Clear" button on the source pane.
### 3.2 Settings View
* **API Configuration:** A text input for the `Ollama API Address` (e.g., `http://100.x.y.z:11434`).
* **Output Control:** A toggle switch for `Enable Streaming` (renders text as it is generated).
* **Prompt Engineering:** A large text area for the `System Prompt Template`.
---
## 4. Functional Requirements
### 4.1 Prompt Engineering Engine
The application must dynamically inject variables into the user-provided template before sending the request to the Ollama API.
**Default Template:**
> You are a professional {SOURCE_LANG} ({SOURCE_CODE}) to {TARGET_LANG} ({TARGET_CODE}) translator. Your goal is to accurately convey the meaning and nuances of the original {SOURCE_LANG} text while adhering to {TARGET_LANG} grammar, vocabulary, and cultural sensitivities.
> Produce only the {TARGET_LANG} translation, without any additional explanations or commentary. Please translate the following {SOURCE_LANG} text into {TARGET_LANG}:
>
> {TEXT}
### 4.2 Template Variables
The engine must support and correctly replace the following tokens:
* `{SOURCE_LANG}`: Full name of the input language (e.g., "Chinese").
* `{SOURCE_CODE}`: Short code of the input language (e.g., "zh-Hans").
* `{TARGET_LANG}`: Full name of the output language (e.g., "English").
* `{TARGET_CODE}`: Short code of the output language (e.g., "en").
* `{TEXT}`: The actual content provided by the user.
### 4.3 Ollama Integration
* Support for the `/api/generate` endpoint.
* Ability to handle both `stream: true` (progressive UI updates) and `stream: false` (batch updates).
* Support for connecting to any valid URL provided in Settings, ensuring compatibility with Tailscale IPs.
---
## 5. Technical Implementation Details
### 5.1 Language Mapping (Default Configuration)
The application will provide a default list of languages:
| Language | Code |
| :--- | :--- |
| English | en |
| Chinese | zh-Hans |
| Japanese | ja |
| Spanish | es |
| French | fr |
| German | de |
### 5.2 Persistence
* All settings (API Address, Stream Toggle, and Custom Prompt Template) must be stored in the user's local application data folder to ensure they are remembered across restarts.
---
## 6. Success Metrics
* **Connectivity:** Seamless communication with an Ollama instance over a Tailscale network.
* **UX:** The "Streaming" mode should feel responsive with no UI lag during high-token-per-second generation.
---

View File

@@ -12,11 +12,19 @@ import {
FileText,
ChevronDown,
Sun,
Moon
Moon,
User,
Type
} from 'lucide-vue-next';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { useSettingsStore, LANGUAGES, DEFAULT_TEMPLATE } from './stores/settings';
import {
useSettingsStore,
LANGUAGES,
DEFAULT_TEMPLATE,
SPEAKER_IDENTITY_OPTIONS,
TONE_REGISTER_OPTIONS
} from './stores/settings';
import pkg from '../package.json';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
@@ -45,20 +53,29 @@ const view = ref<'translate' | 'settings' | 'logs'>('translate');
// Dropdown State
const sourceDropdownOpen = ref(false);
const targetDropdownOpen = ref(false);
const speakerDropdownOpen = ref(false);
const toneDropdownOpen = ref(false);
const closeAllDropdowns = () => {
sourceDropdownOpen.value = false;
targetDropdownOpen.value = false;
speakerDropdownOpen.value = false;
toneDropdownOpen.value = false;
};
const toggleDropdown = (type: 'source' | 'target') => {
if (type === 'source') {
sourceDropdownOpen.value = !sourceDropdownOpen.value;
targetDropdownOpen.value = false;
} else {
targetDropdownOpen.value = !targetDropdownOpen.value;
sourceDropdownOpen.value = false;
}
const toggleDropdown = (type: 'source' | 'target' | 'speaker' | 'tone') => {
const states = {
source: sourceDropdownOpen,
target: targetDropdownOpen,
speaker: speakerDropdownOpen,
tone: toneDropdownOpen
};
const targetState = states[type];
const currentValue = targetState.value;
closeAllDropdowns();
targetState.value = !currentValue;
};
const handleGlobalClick = (e: MouseEvent) => {
@@ -116,6 +133,14 @@ const targetLangCode = computed({
const sourceLang = computed(() => settings.sourceLang);
const targetLang = computed(() => settings.targetLang);
const currentSpeakerLabel = computed(() => {
return SPEAKER_IDENTITY_OPTIONS.find(opt => opt.value === settings.speakerIdentity)?.label || '男性';
});
const currentToneLabel = computed(() => {
return TONE_REGISTER_OPTIONS.find(opt => opt.value === settings.toneRegister)?.label || '正式专业';
});
const swapLanguages = () => {
const temp = { ...settings.sourceLang };
settings.sourceLang = { ...settings.targetLang };
@@ -150,6 +175,8 @@ const translate = async () => {
.replace(/{SOURCE_CODE}/g, sourceLang.value.code)
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
.replace(/{TARGET_CODE}/g, targetLang.value.code)
.replace(/{SPEAKER_IDENTITY}/g, settings.speakerIdentity)
.replace(/{TONE_REGISTER}/g, settings.toneRegister)
.replace(/{TEXT}/g, sourceText.value);
const requestBody = {
@@ -328,6 +355,94 @@ const translate = async () => {
</div>
</transition>
</div>
<!-- Speaker Identity Dropdown -->
<div class="relative lang-dropdown min-w-24">
<button
@click.stop="toggleDropdown('speaker')"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-colors text-sm font-medium text-slate-600 dark:text-slate-300 w-full justify-between group"
title="说话人身份"
>
<div class="flex items-center gap-1.5 truncate">
<User class="w-3.5 h-3.5 text-slate-400" />
<span class="truncate">{{ currentSpeakerLabel }}</span>
</div>
<ChevronDown :class="cn('w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0', speakerDropdownOpen && '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="speakerDropdownOpen"
class="absolute left-0 mt-2 w-40 max-h-80 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
v-for="opt in SPEAKER_IDENTITY_OPTIONS"
:key="opt.value"
@click="settings.speakerIdentity = opt.value; speakerDropdownOpen = false"
:class="cn(
'px-4 py-2 text-sm text-left transition-colors flex items-center justify-between',
settings.speakerIdentity === opt.value ? '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'
)"
>
{{ opt.label }}
<Check v-if="settings.speakerIdentity === opt.value" class="w-3.5 h-3.5" />
</button>
</div>
</transition>
</div>
<!-- Tone & Register Dropdown -->
<div class="relative lang-dropdown min-w-32">
<button
@click.stop="toggleDropdown('tone')"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-colors text-sm font-medium text-slate-600 dark:text-slate-300 w-full justify-between group"
title="语气风格"
>
<div class="flex items-center gap-1.5 truncate">
<Type class="w-3.5 h-3.5 text-slate-400" />
<span class="truncate">{{ currentToneLabel }}</span>
</div>
<ChevronDown :class="cn('w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0', toneDropdownOpen && '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="toneDropdownOpen"
class="absolute left-0 mt-2 w-56 max-h-80 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
v-for="opt in TONE_REGISTER_OPTIONS"
:key="opt.value"
@click="settings.toneRegister = opt.value; toneDropdownOpen = false"
:class="cn(
'px-4 py-2.5 text-sm text-left transition-colors flex flex-col gap-0.5',
settings.toneRegister === opt.value ? '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 items-center justify-between w-full">
{{ opt.label }}
<Check v-if="settings.toneRegister === opt.value" class="w-3.5 h-3.5" />
</div>
<span class="text-[10px] opacity-60 font-normal truncate">{{ opt.description }}</span>
</button>
</div>
</transition>
</div>
<div class="ml-auto flex items-center gap-2">
<button @click="copyTarget" class="p-1.5 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-md transition-colors relative" title="复制结果">
<Check v-if="showCopyFeedback" class="w-4 h-4 text-green-600" />
@@ -403,7 +518,7 @@ 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}', '{SOURCE_CODE}', '{TARGET_LANG}', '{TARGET_CODE}', '{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}', '{SOURCE_CODE}', '{TARGET_LANG}', '{TARGET_CODE}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}', '{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>
</div>
</div>
</div>

View File

@@ -25,7 +25,29 @@ export const LANGUAGES: Language[] = [
{ displayName: '阿拉伯语', englishName: 'Arabic', code: 'ar' },
];
export const SPEAKER_IDENTITY_OPTIONS = [
{ label: '男性', value: 'Male' },
{ label: '女性', value: 'Female' },
{ label: '中性', value: 'Gender-neutral' },
];
export const TONE_REGISTER_OPTIONS = [
{ label: '正式专业', value: 'Formal & Professional', description: '商务邮件、法律合同、官方报告' },
{ label: '礼貌客气', value: 'Polite & Respectful', description: '与长辈、客户或初次见面的人交流' },
{ label: '礼貌随和', value: 'Polite & Conversational', description: '得体但不刻板的日常对话' },
{ label: '中性标准', value: 'Neutral & Standard', description: '维基百科、说明书、客观的新闻报道' },
{ label: '非正式', value: 'Casual & Informal', description: '朋友聊天、社交媒体、非正式简讯' },
{ label: '亲切友好', value: 'Warm & Friendly', description: '社区信函、给朋友的建议、温馨提示' },
{ label: '严谨权威', value: 'Strict & Authoritative', description: '警示标志、强制规定、上级指令' },
{ label: '热情生动', value: 'Enthusiastic & Vivid', description: '广告文案、旅游推荐、博主推文' },
];
export const DEFAULT_TEMPLATE = `You are a professional {SOURCE_LANG} ({SOURCE_CODE}) to {TARGET_LANG} ({TARGET_CODE}) translator. Your goal is to accurately convey the meaning and nuances of the original {SOURCE_LANG} text while adhering to {TARGET_LANG} grammar, vocabulary, and cultural sensitivities.
Translation Context & Style Constraints:
Speaker Identity: {SPEAKER_IDENTITY}. Ensure all grammatical agreements and self-referential terms in {TARGET_LANG} reflect this.
Tone & Register: {TONE_REGISTER}.
Produce only the {TARGET_LANG} translation, without any additional explanations or commentary. Please translate the following {SOURCE_LANG} text into {TARGET_LANG}:
@@ -42,6 +64,9 @@ export const useSettingsStore = defineStore('settings', () => {
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[0].value);
const logs = ref<{ timestamp: string; type: 'request' | 'response' | 'error'; content: any }[]>([]);
const addLog = (type: 'request' | 'response' | 'error', content: any) => {
@@ -64,6 +89,8 @@ export const useSettingsStore = defineStore('settings', () => {
systemPromptTemplate,
sourceLang,
targetLang,
speakerIdentity,
toneRegister,
logs,
addLog
};