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,
|
FileText,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Sun,
|
Sun,
|
||||||
Moon
|
Moon,
|
||||||
|
User,
|
||||||
|
Type
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
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 pkg from '../package.json';
|
||||||
import { clsx, type ClassValue } from 'clsx';
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
@@ -45,20 +53,29 @@ const view = ref<'translate' | 'settings' | 'logs'>('translate');
|
|||||||
// Dropdown State
|
// Dropdown State
|
||||||
const sourceDropdownOpen = ref(false);
|
const sourceDropdownOpen = ref(false);
|
||||||
const targetDropdownOpen = ref(false);
|
const targetDropdownOpen = ref(false);
|
||||||
|
const speakerDropdownOpen = ref(false);
|
||||||
|
const toneDropdownOpen = ref(false);
|
||||||
|
|
||||||
const closeAllDropdowns = () => {
|
const closeAllDropdowns = () => {
|
||||||
sourceDropdownOpen.value = false;
|
sourceDropdownOpen.value = false;
|
||||||
targetDropdownOpen.value = false;
|
targetDropdownOpen.value = false;
|
||||||
|
speakerDropdownOpen.value = false;
|
||||||
|
toneDropdownOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDropdown = (type: 'source' | 'target') => {
|
const toggleDropdown = (type: 'source' | 'target' | 'speaker' | 'tone') => {
|
||||||
if (type === 'source') {
|
const states = {
|
||||||
sourceDropdownOpen.value = !sourceDropdownOpen.value;
|
source: sourceDropdownOpen,
|
||||||
targetDropdownOpen.value = false;
|
target: targetDropdownOpen,
|
||||||
} else {
|
speaker: speakerDropdownOpen,
|
||||||
targetDropdownOpen.value = !targetDropdownOpen.value;
|
tone: toneDropdownOpen
|
||||||
sourceDropdownOpen.value = false;
|
};
|
||||||
}
|
|
||||||
|
const targetState = states[type];
|
||||||
|
const currentValue = targetState.value;
|
||||||
|
|
||||||
|
closeAllDropdowns();
|
||||||
|
targetState.value = !currentValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGlobalClick = (e: MouseEvent) => {
|
const handleGlobalClick = (e: MouseEvent) => {
|
||||||
@@ -116,6 +133,14 @@ const targetLangCode = computed({
|
|||||||
const sourceLang = computed(() => settings.sourceLang);
|
const sourceLang = computed(() => settings.sourceLang);
|
||||||
const targetLang = computed(() => settings.targetLang);
|
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 swapLanguages = () => {
|
||||||
const temp = { ...settings.sourceLang };
|
const temp = { ...settings.sourceLang };
|
||||||
settings.sourceLang = { ...settings.targetLang };
|
settings.sourceLang = { ...settings.targetLang };
|
||||||
@@ -150,6 +175,8 @@ const translate = async () => {
|
|||||||
.replace(/{SOURCE_CODE}/g, sourceLang.value.code)
|
.replace(/{SOURCE_CODE}/g, sourceLang.value.code)
|
||||||
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
|
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
|
||||||
.replace(/{TARGET_CODE}/g, targetLang.value.code)
|
.replace(/{TARGET_CODE}/g, targetLang.value.code)
|
||||||
|
.replace(/{SPEAKER_IDENTITY}/g, settings.speakerIdentity)
|
||||||
|
.replace(/{TONE_REGISTER}/g, settings.toneRegister)
|
||||||
.replace(/{TEXT}/g, sourceText.value);
|
.replace(/{TEXT}/g, sourceText.value);
|
||||||
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
@@ -328,6 +355,94 @@ const translate = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</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">
|
<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="复制结果">
|
<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" />
|
<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"
|
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}', '{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,29 @@ export const LANGUAGES: Language[] = [
|
|||||||
{ displayName: '阿拉伯语', englishName: 'Arabic', code: 'ar' },
|
{ 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.
|
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}:
|
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 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[0].value);
|
||||||
|
|
||||||
const logs = ref<{ timestamp: string; type: 'request' | 'response' | 'error'; content: any }[]>([]);
|
const logs = ref<{ timestamp: string; type: 'request' | 'response' | 'error'; content: any }[]>([]);
|
||||||
|
|
||||||
const addLog = (type: 'request' | 'response' | 'error', content: any) => {
|
const addLog = (type: 'request' | 'response' | 'error', content: any) => {
|
||||||
@@ -64,6 +89,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
systemPromptTemplate,
|
systemPromptTemplate,
|
||||||
sourceLang,
|
sourceLang,
|
||||||
targetLang,
|
targetLang,
|
||||||
|
speakerIdentity,
|
||||||
|
toneRegister,
|
||||||
logs,
|
logs,
|
||||||
addLog
|
addLog
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user