Compare commits
21 Commits
786dec41e4
...
v0.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1b3c3b142 | ||
|
|
a8e14269af | ||
|
|
f21366e55f | ||
|
|
6ff6fd3f25 | ||
|
|
f6bc4152b0 | ||
|
|
5b493ce767 | ||
|
|
49b96eeaeb | ||
|
|
d259b54f11 | ||
|
|
484dd17f1c | ||
|
|
985cfd933a | ||
|
|
d9f0af53c4 | ||
|
|
f183cccd5b | ||
|
|
44c98f1d44 | ||
|
|
f7f7556b98 | ||
|
|
f84ee6ced7 | ||
|
|
8df295cbf5 | ||
|
|
2c61c19093 | ||
|
|
fb98ab5472 | ||
|
|
ce358e5b76 | ||
|
|
48d9c6e240 | ||
|
|
74fe954478 |
@@ -1,7 +1,7 @@
|
||||
# Tauri + Vue + TypeScript
|
||||
# AI Translate Client
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
使用 AI 模型进行翻译的客户端。使用 Gemini 编写。
|
||||
|
||||
## Recommended IDE Setup
|
||||
更新版本:手动修改 `package.json` 文件中的版本号,然后运行 `pnpm sync-version` 。
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
图标由 Gemini 生成。
|
||||
|
||||
BIN
app-icon.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
<title>AI Translate Client</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gemmatrans-client",
|
||||
"name": "ai-translate-client",
|
||||
"private": true,
|
||||
"version": "0.1.2",
|
||||
"version": "0.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
191
src-tauri/Cargo.lock
generated
@@ -17,6 +17,20 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ai-translate-client"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-opener",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
@@ -554,7 +568,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -996,6 +1010,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1003,7 +1026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1017,6 +1040,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1234,18 +1263,6 @@ dependencies = [
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gemmatrans-client"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-opener",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -1609,6 +1626,22 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2156,6 +2189,23 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2465,6 +2515,50 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -3150,16 +3244,19 @@ dependencies = [
|
||||
"cookie_store 0.22.1",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3170,13 +3267,16 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
@@ -3211,7 +3311,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3313,6 +3413,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -3370,6 +3479,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -4316,6 +4448,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -4676,6 +4818,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -4838,6 +4986,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "gemmatrans-client"
|
||||
version = "0.1.2"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
name = "ai-translate-client"
|
||||
version = "0.3.2"
|
||||
description = "A client using AI models to translate"
|
||||
authors = ["Julian"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@@ -11,7 +11,7 @@ edition = "2021"
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "gemmatrans_client_lib"
|
||||
name = "ai_translate_client_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
@@ -23,4 +23,6 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-http = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.1 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 179 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 252 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 997 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
@@ -1,7 +1,93 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct Message {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct TranslationPayload {
|
||||
model: String,
|
||||
messages: Vec<Message>,
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAIResponse {
|
||||
choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Choice {
|
||||
message: Option<Message>,
|
||||
delta: Option<Delta>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Delta {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
async fn translate(
|
||||
app: AppHandle,
|
||||
api_address: String,
|
||||
api_key: String,
|
||||
payload: TranslationPayload,
|
||||
) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
// Ensure URL doesn't have double slashes if api_address ends with /
|
||||
let base_url = api_address.trim_end_matches('/');
|
||||
let url = format!("{}/chat/completions", base_url);
|
||||
|
||||
let mut request = client.post(&url).json(&payload);
|
||||
|
||||
if !api_key.is_empty() {
|
||||
request = request.header("Authorization", format!("Bearer {}", api_key));
|
||||
}
|
||||
|
||||
let res = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !payload.stream {
|
||||
let data = res.json::<OpenAIResponse>().await.map_err(|e| e.to_string())?;
|
||||
return Ok(data.choices.get(0).and_then(|c| c.message.as_ref()).map(|m| m.content.clone()).unwrap_or_default());
|
||||
}
|
||||
|
||||
let mut stream = res.bytes_stream();
|
||||
let mut full_response = String::new();
|
||||
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item.map_err(|e| e.to_string())?;
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() { continue; }
|
||||
if line == "data: [DONE]" { break; }
|
||||
|
||||
if let Some(data_str) = line.strip_prefix("data: ") {
|
||||
if let Ok(json) = serde_json::from_str::<OpenAIResponse>(data_str) {
|
||||
if let Some(choice) = json.choices.get(0) {
|
||||
if let Some(delta) = &choice.delta {
|
||||
if let Some(content) = &delta.content {
|
||||
full_response.push_str(content);
|
||||
app.emit("translation-chunk", content).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(full_response)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -9,7 +95,7 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.invoke_handler(tauri::generate_handler![translate])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
gemmatrans_client_lib::run()
|
||||
ai_translate_client_lib::run()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "gemmatrans-client",
|
||||
"version": "0.1.2",
|
||||
"identifier": "top.volan.gemmatrans-client",
|
||||
"productName": "ai-translate-client",
|
||||
"version": "0.3.2",
|
||||
"identifier": "top.volan.ai-translate-client",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "gemmatrans-client",
|
||||
"title": "AI 翻译客户端",
|
||||
"width": 1400,
|
||||
"height": 900
|
||||
}
|
||||
|
||||
679
src/App.vue
@@ -12,10 +12,24 @@ import {
|
||||
FileText,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
Moon
|
||||
Moon,
|
||||
User,
|
||||
Type,
|
||||
Plus,
|
||||
Save,
|
||||
Play
|
||||
} from 'lucide-vue-next';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { useSettingsStore, LANGUAGES, DEFAULT_TEMPLATE } from './stores/settings';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
useSettingsStore,
|
||||
LANGUAGES,
|
||||
DEFAULT_TEMPLATE,
|
||||
DEFAULT_EVALUATION_TEMPLATE,
|
||||
SPEAKER_IDENTITY_OPTIONS,
|
||||
TONE_REGISTER_OPTIONS,
|
||||
type ApiProfile
|
||||
} from './stores/settings';
|
||||
import pkg from '../package.json';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -41,23 +55,65 @@ const toggleTheme = () => {
|
||||
|
||||
const view = ref<'translate' | 'settings' | 'logs'>('translate');
|
||||
|
||||
// Profile Management
|
||||
const newProfileName = ref('');
|
||||
const isSavingProfile = ref(false);
|
||||
|
||||
const saveCurrentAsProfile = () => {
|
||||
if (!newProfileName.value.trim()) return;
|
||||
|
||||
const newProfile: ApiProfile = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newProfileName.value.trim(),
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
modelName: settings.modelName
|
||||
};
|
||||
|
||||
settings.profiles.push(newProfile);
|
||||
newProfileName.value = '';
|
||||
isSavingProfile.value = false;
|
||||
};
|
||||
|
||||
const applyProfile = (p: ApiProfile) => {
|
||||
settings.apiBaseUrl = p.apiBaseUrl;
|
||||
settings.apiKey = p.apiKey;
|
||||
settings.modelName = p.modelName;
|
||||
};
|
||||
|
||||
const deleteProfile = (id: string) => {
|
||||
settings.profiles = settings.profiles.filter(p => p.id !== id);
|
||||
};
|
||||
|
||||
// Dropdown State
|
||||
const sourceDropdownOpen = ref(false);
|
||||
const targetDropdownOpen = ref(false);
|
||||
const speakerDropdownOpen = ref(false);
|
||||
const toneDropdownOpen = ref(false);
|
||||
const evaluationProfileDropdownOpen = ref(false);
|
||||
|
||||
const closeAllDropdowns = () => {
|
||||
sourceDropdownOpen.value = false;
|
||||
targetDropdownOpen.value = false;
|
||||
speakerDropdownOpen.value = false;
|
||||
toneDropdownOpen.value = false;
|
||||
evaluationProfileDropdownOpen.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' | 'evaluationProfile') => {
|
||||
const states = {
|
||||
source: sourceDropdownOpen,
|
||||
target: targetDropdownOpen,
|
||||
speaker: speakerDropdownOpen,
|
||||
tone: toneDropdownOpen,
|
||||
evaluationProfile: evaluationProfileDropdownOpen
|
||||
};
|
||||
|
||||
const targetState = states[type];
|
||||
const currentValue = targetState.value;
|
||||
|
||||
closeAllDropdowns();
|
||||
targetState.value = !currentValue;
|
||||
};
|
||||
|
||||
const handleGlobalClick = (e: MouseEvent) => {
|
||||
@@ -77,10 +133,34 @@ onUnmounted(() => {
|
||||
|
||||
// Translation State
|
||||
const sourceText = ref('');
|
||||
const context = ref('');
|
||||
const targetText = ref('');
|
||||
const isTranslating = ref(false);
|
||||
const showCopyFeedback = ref(false);
|
||||
|
||||
interface EvaluationResult {
|
||||
score: number;
|
||||
analysis: string;
|
||||
improvements?: string;
|
||||
}
|
||||
|
||||
const evaluationResult = ref<EvaluationResult | null>(null);
|
||||
const isEvaluating = ref(false);
|
||||
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
unlisten = await listen<string>('translation-chunk', (event) => {
|
||||
if (isTranslating.value) {
|
||||
targetText.value += event.payload;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlisten) unlisten();
|
||||
});
|
||||
|
||||
// Language Selection
|
||||
const sourceLangCode = computed({
|
||||
get: () => settings.sourceLang.code,
|
||||
@@ -101,6 +181,20 @@ 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 currentEvaluationProfileLabel = computed(() => {
|
||||
if (!settings.evaluationProfileId) return '使用主翻译配置(默认)';
|
||||
const profile = settings.profiles.find(p => p.id === settings.evaluationProfileId);
|
||||
return profile ? `${profile.name} — ${profile.modelName}` : '使用主翻译配置(默认)';
|
||||
});
|
||||
|
||||
const swapLanguages = () => {
|
||||
const temp = { ...settings.sourceLang };
|
||||
settings.sourceLang = { ...settings.targetLang };
|
||||
@@ -110,6 +204,7 @@ const swapLanguages = () => {
|
||||
const clearSource = () => {
|
||||
sourceText.value = '';
|
||||
targetText.value = '';
|
||||
evaluationResult.value = null;
|
||||
};
|
||||
|
||||
const copyTarget = async () => {
|
||||
@@ -124,72 +219,118 @@ const copyTarget = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateTranslation = async () => {
|
||||
if (!targetText.value) return;
|
||||
|
||||
isEvaluating.value = true;
|
||||
evaluationResult.value = null;
|
||||
|
||||
// Determine which API config to use for evaluation
|
||||
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 evaluationPrompt = 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);
|
||||
|
||||
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 }
|
||||
],
|
||||
stream: false // Non-streaming for evaluation to parse JSON
|
||||
};
|
||||
|
||||
settings.addLog('request', { type: 'evaluation', ...requestBody });
|
||||
|
||||
try {
|
||||
const response = await invoke<string>('translate', {
|
||||
apiAddress: apiBaseUrl,
|
||||
apiKey: apiKey,
|
||||
payload: requestBody
|
||||
});
|
||||
|
||||
try {
|
||||
// Try to extract JSON if the model wrapped it in code blocks
|
||||
const jsonStr = response.replace(/```json\s?|\s?```/g, '').trim();
|
||||
evaluationResult.value = JSON.parse(jsonStr);
|
||||
settings.addLog('response', { type: 'evaluation', content: evaluationResult.value });
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse evaluation result:', response);
|
||||
settings.addLog('error', `Evaluation parsing error: ${response}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
settings.addLog('error', `Evaluation error: ${String(err)}`);
|
||||
} finally {
|
||||
isEvaluating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const translate = async () => {
|
||||
if (!sourceText.value.trim() || isTranslating.value) return;
|
||||
|
||||
isTranslating.value = true;
|
||||
targetText.value = '';
|
||||
evaluationResult.value = null;
|
||||
|
||||
const prompt = settings.systemPromptTemplate
|
||||
const systemMessage = settings.systemPromptTemplate
|
||||
.replace(/{SOURCE_LANG}/g, sourceLang.value.englishName)
|
||||
.replace(/{SOURCE_CODE}/g, sourceLang.value.code)
|
||||
.replace(/{TARGET_LANG}/g, targetLang.value.englishName)
|
||||
.replace(/{TARGET_CODE}/g, targetLang.value.code)
|
||||
.replace(/{TEXT}/g, sourceText.value);
|
||||
.replace(/{SPEAKER_IDENTITY}/g, settings.speakerIdentity)
|
||||
.replace(/{TONE_REGISTER}/g, settings.toneRegister);
|
||||
|
||||
const userMessage = context.value
|
||||
? `[Context]\n${context.value}\n\n[Text to Translate]\n${sourceText.value}`
|
||||
: `[Text to Translate]\n${sourceText.value}`;
|
||||
|
||||
const requestBody = {
|
||||
model: settings.modelName,
|
||||
prompt: prompt,
|
||||
messages: [
|
||||
{ role: "system", content: systemMessage },
|
||||
{ role: "user", content: userMessage }
|
||||
],
|
||||
stream: settings.enableStreaming
|
||||
};
|
||||
|
||||
settings.addLog('request', requestBody);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${settings.ollamaApiAddress}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
const response = await invoke<string>('translate', {
|
||||
apiAddress: settings.apiBaseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
payload: requestBody
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
settings.addLog('error', { status: response.status, text: errorText });
|
||||
throw new Error(`API error (${response.status}): ${errorText || response.statusText}`);
|
||||
// For non-streaming, response is returned as string
|
||||
if (!settings.enableStreaming) {
|
||||
targetText.value = response;
|
||||
}
|
||||
settings.addLog('response', 'Translation completed');
|
||||
|
||||
if (settings.enableStreaming) {
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.response) {
|
||||
targetText.value += data.response;
|
||||
}
|
||||
if (data.done) {
|
||||
settings.addLog('response', 'Stream finished');
|
||||
}
|
||||
} catch (e) {
|
||||
settings.addLog('error', `Chunk parse error: ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await response.json();
|
||||
settings.addLog('response', data);
|
||||
targetText.value = data.response;
|
||||
// Trigger evaluation if enabled
|
||||
if (settings.enableEvaluation) {
|
||||
await evaluateTranslation();
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = String(err);
|
||||
settings.addLog('error', errorMsg);
|
||||
targetText.value = `Error: ${errorMsg}`;
|
||||
} finally {
|
||||
@@ -199,17 +340,17 @@ const translate = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 font-sans selection:bg-blue-100 dark:selection:bg-blue-900 flex flex-col">
|
||||
<div class="h-screen bg-slate-100/50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 font-sans selection:bg-blue-100 dark:selection:bg-blue-900 flex flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<header class="h-14 border-b dark:border-slate-800 bg-white dark:bg-slate-900 flex items-center justify-between px-6 shrink-0 sticky top-0 z-10 shadow-sm">
|
||||
<header class="h-14 border-b dark:border-slate-800 bg-slate-50 dark:bg-slate-900 flex items-center justify-between px-6 shrink-0 sticky top-0 z-10 shadow-sm/5">
|
||||
<div class="flex items-center gap-2 cursor-pointer group" @click="view = 'translate'">
|
||||
<Languages class="w-6 h-6 text-blue-600 group-hover:scale-110 transition-transform" />
|
||||
<h1 class="font-semibold text-lg tracking-tight">TranslateGemma</h1>
|
||||
<h1 class="font-semibold text-lg tracking-tight">AI 翻译</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="p-2 rounded-full transition-colors hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300"
|
||||
class="p-2 rounded-full transition-colors hover:bg-slate-200/50 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300"
|
||||
:title="settings.isDark ? '切换亮色主题' : '切换暗色主题'"
|
||||
>
|
||||
<Sun v-if="settings.isDark" class="w-5 h-5" />
|
||||
@@ -217,29 +358,29 @@ const translate = async () => {
|
||||
</button>
|
||||
<button
|
||||
@click="view = 'settings'"
|
||||
:class="cn('p-2 rounded-full transition-colors', view === 'settings' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300')"
|
||||
:class="cn('p-2 rounded-full transition-colors', view === 'settings' ? '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="设置"
|
||||
>
|
||||
<Settings class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
@click="view = 'logs'"
|
||||
:class="cn('p-2 rounded-full transition-colors', view === 'logs' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : 'hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300')"
|
||||
title="调试日志"
|
||||
:class="cn('p-2 rounded-full transition-colors', view === 'logs' ? '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="日志"
|
||||
>
|
||||
<FileText class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<main class="flex-1 flex overflow-hidden min-h-0 relative">
|
||||
<!-- Translation View -->
|
||||
<div v-if="view === 'translate'" class="flex-1 flex flex-col md:flex-row divide-y md:divide-y-0 md:divide-x dark:divide-slate-800 bg-white dark:bg-slate-900 overflow-hidden">
|
||||
<div v-if="view === 'translate'" class="flex-1 flex flex-col md:flex-row divide-y md:divide-y-0 md:divide-x dark:divide-slate-800 bg-white/50 dark:bg-slate-900 overflow-hidden h-full">
|
||||
<!-- Source Pane -->
|
||||
<div class="flex-1 flex flex-col min-h-0 relative">
|
||||
<div class="flex items-center gap-3 px-6 py-3 border-b dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30 relative z-40">
|
||||
<div class="flex-1 flex flex-col min-h-0 relative h-full">
|
||||
<div class="flex items-center gap-3 px-6 py-3 border-b dark:border-slate-800 bg-slate-100/40 dark:bg-slate-800/30 relative z-40 shrink-0">
|
||||
<!-- Custom Source Dropdown -->
|
||||
<div class="relative lang-dropdown min-w-[120px]">
|
||||
<div class="relative lang-dropdown min-w-30">
|
||||
<button
|
||||
@click.stop="toggleDropdown('source')"
|
||||
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-semibold text-slate-700 dark:text-slate-200 w-full justify-between group"
|
||||
@@ -259,7 +400,7 @@ const translate = async () => {
|
||||
>
|
||||
<div
|
||||
v-if="sourceDropdownOpen"
|
||||
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 py-1 flex flex-col custom-scrollbar"
|
||||
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="lang in LANGUAGES"
|
||||
@@ -286,13 +427,36 @@ const translate = async () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="sourceText"
|
||||
placeholder="请输入待翻译内容..."
|
||||
class="flex-1 p-6 resize-none outline-none text-lg leading-relaxed placeholder:text-slate-300 dark:placeholder:text-slate-600 bg-transparent"
|
||||
></textarea>
|
||||
<div class="p-4 border-t dark:border-slate-800 flex justify-end">
|
||||
<button
|
||||
<textarea
|
||||
v-model="sourceText"
|
||||
placeholder="请输入待翻译内容..."
|
||||
class="flex-1 p-6 resize-none outline-none text-lg leading-relaxed placeholder:text-slate-300 dark:placeholder:text-slate-600 bg-transparent min-h-0"
|
||||
></textarea>
|
||||
|
||||
<!-- Context Input Area -->
|
||||
<div class="px-6 py-3 bg-slate-200/20 dark:bg-slate-800/20 border-t border-dashed dark:border-slate-800 group/context relative">
|
||||
<div class="flex items-center justify-between mb-1.5 h-5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileText class="w-4 h-4 text-slate-400" />
|
||||
<span class="text-[12px] font-bold text-slate-400 uppercase tracking-widest">情景背景 (可选)</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="context"
|
||||
@click="context = ''"
|
||||
class="p-1 hover:bg-slate-200 dark:hover:bg-slate-700 rounded opacity-0 group-hover/context:opacity-100 transition-opacity"
|
||||
title="清空背景"
|
||||
>
|
||||
<Plus class="w-3 h-3 rotate-45 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="context"
|
||||
placeholder="在此输入背景信息,有助于提升翻译准确度..."
|
||||
class="w-full bg-transparent border-none outline-none text-sm text-slate-500 dark:text-slate-400 resize-none h-14 leading-normal placeholder:italic placeholder:text-slate-300 dark:placeholder:text-slate-600"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<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()"
|
||||
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"
|
||||
@@ -305,10 +469,10 @@ const translate = async () => {
|
||||
</div>
|
||||
|
||||
<!-- Target Pane -->
|
||||
<div class="flex-1 flex flex-col min-h-0 bg-slate-50/30 dark:bg-slate-900/50 relative">
|
||||
<div class="flex items-center gap-3 px-6 py-3 border-b dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30 relative z-40">
|
||||
<div class="flex-1 flex flex-col min-h-0 bg-slate-100/20 dark:bg-slate-900/50 relative h-full">
|
||||
<div class="flex items-center gap-3 px-6 py-3 border-b dark:border-slate-800 bg-slate-100/40 dark:bg-slate-800/30 relative z-40 shrink-0">
|
||||
<!-- Custom Target Dropdown -->
|
||||
<div class="relative lang-dropdown min-w-[120px]">
|
||||
<div class="relative lang-dropdown min-w-30">
|
||||
<button
|
||||
@click.stop="toggleDropdown('target')"
|
||||
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-semibold text-slate-700 dark:text-slate-200 w-full justify-between"
|
||||
@@ -345,6 +509,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" />
|
||||
@@ -352,42 +604,178 @@ const translate = async () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 p-6 overflow-y-auto text-lg leading-relaxed whitespace-pre-wrap">
|
||||
<div class="flex-1 p-6 overflow-y-auto text-lg leading-relaxed whitespace-pre-wrap min-h-0">
|
||||
<template v-if="targetText">
|
||||
{{ targetText }}
|
||||
</template>
|
||||
<span v-else class="text-slate-300 dark:text-slate-600 italic">翻译结果将在此显示...</span>
|
||||
|
||||
<!-- 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>
|
||||
</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 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>
|
||||
</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>
|
||||
</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">
|
||||
<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"
|
||||
>
|
||||
<Loader2 v-if="isEvaluating" class="w-4 h-4 animate-spin" />
|
||||
<Check v-else class="w-4 h-4" />
|
||||
{{ isEvaluating ? '正在审计...' : '审计' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings View -->
|
||||
<div v-else-if="view === 'settings'" class="flex-1 overflow-y-auto bg-slate-50 dark:bg-slate-950 p-6 md:p-10">
|
||||
<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">
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">API 配置</h2>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border dark:border-slate-800 p-6 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Ollama API 地址</label>
|
||||
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">API 配置预设</h2>
|
||||
<div class="bg-white/60 dark:bg-slate-900 rounded-xl shadow-sm border dark:border-slate-800 p-1.5 space-y-1">
|
||||
<div v-if="settings.profiles.length === 0" class="p-8 text-center text-sm text-slate-400 dark:text-slate-600 italic">
|
||||
暂无预设配置
|
||||
</div>
|
||||
<div
|
||||
v-for="profile in settings.profiles"
|
||||
:key="profile.id"
|
||||
class="p-3 flex items-center justify-between group hover:bg-slate-100/50 dark:hover:bg-slate-800/50 transition-colors rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200 truncate">{{ profile.name }}</span>
|
||||
<div class="flex items-center gap-2 text-[10px] text-slate-400 dark:text-slate-500 font-mono">
|
||||
<span class="truncate max-w-30">{{ profile.modelName }}</span>
|
||||
<span>•</span>
|
||||
<span class="truncate">{{ profile.apiBaseUrl }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click="applyProfile(profile)"
|
||||
class="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-colors"
|
||||
title="应用此配置"
|
||||
>
|
||||
<Play class="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteProfile(profile.id)"
|
||||
class="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">模型接口配置</h2>
|
||||
<div v-if="!isSavingProfile" class="flex items-center gap-2">
|
||||
<button
|
||||
@click="isSavingProfile = true"
|
||||
class="text-xs flex items-center gap-1.5 text-blue-600 dark:text-blue-400 hover:underline px-2 py-1 rounded"
|
||||
>
|
||||
<Save class="w-3.5 h-3.5" />
|
||||
保存为预设
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 bg-white dark:bg-slate-800 p-1 rounded-lg border dark:border-slate-700 shadow-sm animate-in fade-in zoom-in duration-200">
|
||||
<input
|
||||
v-model="settings.ollamaApiAddress"
|
||||
v-model="newProfileName"
|
||||
type="text"
|
||||
placeholder="输入预设名称..."
|
||||
class="text-xs px-2 py-1 bg-transparent outline-none w-32 dark:text-slate-200"
|
||||
@keyup.enter="saveCurrentAsProfile"
|
||||
/>
|
||||
<button
|
||||
@click="saveCurrentAsProfile"
|
||||
:disabled="!newProfileName.trim()"
|
||||
class="p-1 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 rounded disabled:opacity-50"
|
||||
>
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
@click="isSavingProfile = false; newProfileName = ''"
|
||||
class="p-1 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 rounded"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 rotate-45" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-200/20 dark:bg-slate-900 rounded-xl shadow-sm/5 border dark:border-slate-800 p-6 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">API Base URL</label>
|
||||
<input
|
||||
v-model="settings.apiBaseUrl"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 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-slate-900 dark:text-slate-100"
|
||||
placeholder="http://localhost:11434"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Ollama 模型名称</label>
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">API Key</label>
|
||||
<input
|
||||
v-model="settings.apiKey"
|
||||
type="password"
|
||||
class="w-full px-4 py-2 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-sm text-slate-900 dark:text-slate-100"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Model</label>
|
||||
<input
|
||||
v-model="settings.modelName"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 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-sm text-slate-900 dark:text-slate-100"
|
||||
placeholder="translategemma:12b"
|
||||
placeholder="gpt-3.5-turbo"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-500">在生成时即时渲染文本</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -403,36 +791,134 @@ const translate = async () => {
|
||||
)"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">自动质量审计</label>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-500">翻译完成后自动评估准确度</p>
|
||||
</div>
|
||||
<button
|
||||
@click="settings.enableEvaluation = !settings.enableEvaluation"
|
||||
:class="cn(
|
||||
'w-12 h-6 rounded-full transition-colors relative',
|
||||
settings.enableEvaluation ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-700'
|
||||
)"
|
||||
>
|
||||
<div :class="cn(
|
||||
'absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform',
|
||||
settings.enableEvaluation ? 'translate-x-6' : 'translate-x-0'
|
||||
)"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 pt-4 border-t border-dashed dark:border-slate-800 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Settings class="w-3.5 h-3.5 text-blue-500" />
|
||||
<label class="text-sm font-semibold text-slate-700 dark:text-slate-300">审计模型</label>
|
||||
</div>
|
||||
|
||||
<!-- Custom Evaluation Profile Dropdown -->
|
||||
<div class="relative lang-dropdown">
|
||||
<button
|
||||
@click.stop="toggleDropdown('evaluationProfile')"
|
||||
class="flex items-center justify-between w-full px-4 py-2.5 border dark:border-slate-700 rounded-xl bg-slate-50/50 dark:bg-slate-800/30 hover:bg-slate-100/50 dark:hover:bg-slate-800/50 transition-all text-sm text-slate-700 dark:text-slate-200 group"
|
||||
>
|
||||
<span class="truncate">{{ currentEvaluationProfileLabel }}</span>
|
||||
<ChevronDown :class="cn('w-4 h-4 text-slate-400 transition-transform duration-200 group-hover:text-blue-500', evaluationProfileDropdownOpen && '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="evaluationProfileDropdownOpen"
|
||||
class="absolute left-0 mt-2 w-full max-h-60 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
|
||||
@click="settings.evaluationProfileId = null; evaluationProfileDropdownOpen = false"
|
||||
:class="cn(
|
||||
'px-4 py-2.5 text-sm text-left transition-colors flex items-center justify-between',
|
||||
settings.evaluationProfileId === null ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 font-bold' : 'text-slate-500 hover:bg-slate-50 dark:hover:bg-slate-700/50'
|
||||
)"
|
||||
>
|
||||
使用主翻译配置(默认)
|
||||
<Check v-if="settings.evaluationProfileId === null" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="h-px bg-slate-100 dark:bg-slate-700 my-1 mx-2"></div>
|
||||
<button
|
||||
v-for="profile in settings.profiles"
|
||||
:key="profile.id"
|
||||
@click="settings.evaluationProfileId = profile.id; evaluationProfileDropdownOpen = false"
|
||||
:class="cn(
|
||||
'px-4 py-2.5 text-sm text-left transition-colors flex items-center justify-between',
|
||||
settings.evaluationProfileId === profile.id ? '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 flex-col min-w-0">
|
||||
<span class="truncate">{{ profile.name }}</span>
|
||||
<span class="text-[10px] opacity-60 font-mono">{{ profile.modelName }}</span>
|
||||
</div>
|
||||
<Check v-if="settings.evaluationProfileId === profile.id" class="w-3.5 h-3.5 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-500 pl-1">
|
||||
提示:建议为审计选择更强大的模型以获得更精准的反馈
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">提示词工程</h2>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border dark:border-slate-800 p-6">
|
||||
<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
|
||||
v-model="settings.systemPromptTemplate"
|
||||
rows="9"
|
||||
rows="6"
|
||||
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_CODE}', '{TARGET_CODE}', '{SOURCE_LANG}', '{TARGET_LANG}', '{SPEAKER_IDENTITY}', '{TONE_REGISTER}']" :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.evaluationPromptTemplate = DEFAULT_EVALUATION_TEMPLATE" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">恢复默认值</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="settings.evaluationPromptTemplate"
|
||||
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}', '{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs View -->
|
||||
<div v-else-if="view === 'logs'" class="flex-1 overflow-y-auto bg-slate-50 dark:bg-slate-950 p-6 md:p-10">
|
||||
<div v-else-if="view === 'logs'" 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-3xl mx-auto space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">调试日志</h2>
|
||||
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">日志</h2>
|
||||
<button
|
||||
@click="settings.logs = []"
|
||||
class="text-xs text-red-600 dark:text-red-400 hover:underline flex items-center gap-1"
|
||||
@@ -441,7 +927,7 @@ const translate = async () => {
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl shadow-sm border dark:border-slate-800 p-4 space-y-4">
|
||||
<div class="bg-slate-200/20 dark:bg-slate-900 rounded-xl shadow-sm/5 border dark:border-slate-800 p-4 space-y-4">
|
||||
<div v-if="settings.logs.length === 0" class="text-sm text-slate-400 dark:text-slate-500 text-center py-10 italic">
|
||||
暂无日志记录。请尝试进行翻译。
|
||||
</div>
|
||||
@@ -469,14 +955,13 @@ const translate = async () => {
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="h-8 bg-slate-100 dark:bg-slate-900 border-t dark:border-slate-800 flex items-center px-4 justify-between shrink-0">
|
||||
<footer class="h-8 bg-slate-200/40 dark:bg-slate-900 border-t dark:border-slate-800 flex items-center px-4 justify-between shrink-0">
|
||||
<div class="text-[10px] text-slate-400 dark:text-slate-500">
|
||||
{{ settings.ollamaApiAddress }}
|
||||
{{ settings.modelName }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-400 dark:text-slate-500">
|
||||
GemmaTrans v{{ pkg.version }}
|
||||
Client v{{ pkg.version }}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,45 +3,113 @@ import { defineStore } from 'pinia';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
export interface Language {
|
||||
displayName: string; // UI 显示的中文名,如 "英语(英国)"
|
||||
displayName: string; // UI 显示的中文名,如 "英语(英国)"
|
||||
englishName: string; // 文件中的第二列,用于 {SOURCE_LANG}
|
||||
code: string; // 文件中的第一列,用于 {SOURCE_CODE}
|
||||
}
|
||||
|
||||
export const LANGUAGES: Language[] = [
|
||||
{ displayName: '中文(简体)', englishName: 'Simplified Chinese', code: 'zh-Hans' },
|
||||
{ displayName: '中文(繁体)', englishName: 'Traditional Chinese', code: 'zh-Hant' },
|
||||
{ displayName: '英语', englishName: 'English', code: 'en' },
|
||||
{ displayName: '中文(简体)', englishName: 'Simplified Chinese', code: 'zh-Hans' },
|
||||
{ displayName: '中文(繁体)', englishName: 'Traditional Chinese', code: 'zh-Hant' },
|
||||
{ displayName: '英语(美国)', englishName: 'American English', code: 'en-US' },
|
||||
{ displayName: '英语(英国)', englishName: 'British English', code: 'en-GB' },
|
||||
{ displayName: '西班牙语', englishName: 'Spanish', code: 'es' },
|
||||
{ displayName: '葡萄牙语', englishName: 'Portuguese', code: 'pt' },
|
||||
{ displayName: '日语', englishName: 'Japanese', code: 'ja' },
|
||||
{ displayName: '韩语', englishName: 'Korean', code: 'ko' },
|
||||
{ displayName: '法语', englishName: 'French', code: 'fr' },
|
||||
{ displayName: '德语', englishName: 'German', code: 'de' },
|
||||
{ displayName: '西班牙语', englishName: 'Spanish', code: 'es' },
|
||||
{ displayName: '意大利语', englishName: 'Italian', code: 'it' },
|
||||
{ displayName: '俄语', englishName: 'Russian', code: 'ru' },
|
||||
{ displayName: '葡萄牙语', englishName: 'Portuguese', code: 'pt' },
|
||||
{ displayName: '越南语', englishName: 'Vietnamese', code: 'vi' },
|
||||
{ displayName: '泰语', englishName: 'Thai', code: 'th' },
|
||||
{ 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.
|
||||
Produce only the {TARGET_LANG} translation, without any additional explanations or commentary. Please translate the following {SOURCE_LANG} text into {TARGET_LANG}:
|
||||
|
||||
[Constraints]
|
||||
1. Speaker Identity: {SPEAKER_IDENTITY}. Ensure all grammatical agreements and self-referential terms in {TARGET_LANG} reflect this.
|
||||
2. Tone & Register: {TONE_REGISTER}.
|
||||
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].`;
|
||||
|
||||
{TEXT}`;
|
||||
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.
|
||||
|
||||
[Context Info]
|
||||
- Source Language: {SOURCE_LANG}
|
||||
- Target Language: {TARGET_LANG}
|
||||
- Speaker Identity: {SPEAKER_IDENTITY}
|
||||
- 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"
|
||||
}`;
|
||||
|
||||
export interface ApiProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
apiBaseUrl: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const isDark = useLocalStorage('is-dark', false);
|
||||
const ollamaApiAddress = useLocalStorage('ollama-api-address', 'http://localhost:11434');
|
||||
const apiBaseUrl = useLocalStorage('api-base-url', 'http://localhost:11434/v1');
|
||||
const apiKey = useLocalStorage('api-key', '');
|
||||
const modelName = useLocalStorage('model-name', 'translategemma:12b');
|
||||
const profiles = useLocalStorage<ApiProfile[]>('api-profiles', []);
|
||||
const enableStreaming = useLocalStorage('enable-streaming', true);
|
||||
const systemPromptTemplate = useLocalStorage('system-prompt-template', DEFAULT_TEMPLATE);
|
||||
|
||||
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 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 logs = ref<{ timestamp: string; type: 'request' | 'response' | 'error'; content: any }[]>([]);
|
||||
|
||||
const addLog = (type: 'request' | 'response' | 'error', content: any) => {
|
||||
@@ -58,12 +126,19 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
return {
|
||||
isDark,
|
||||
ollamaApiAddress,
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
modelName,
|
||||
profiles,
|
||||
enableStreaming,
|
||||
systemPromptTemplate,
|
||||
enableEvaluation,
|
||||
evaluationPromptTemplate,
|
||||
evaluationProfileId,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
speakerIdentity,
|
||||
toneRegister,
|
||||
logs,
|
||||
addLog
|
||||
};
|
||||
|
||||