add identity and tone
This commit is contained in:
85
spec/prd.md
85
spec/prd.md
@@ -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.
|
||||
|
||||
---
|
||||
137
src/App.vue
137
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user