Compare commits
10 Commits
786dec41e4
...
f183cccd5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + Vue + Typescript App</title>
|
<title>AI Translate Client</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gemmatrans-client",
|
"name": "ai-translate-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.2",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ai-translate-client"
|
||||||
|
version = "0.2.1"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-build",
|
||||||
|
"tauri-plugin-http",
|
||||||
|
"tauri-plugin-opener",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alloc-no-stdlib"
|
name = "alloc-no-stdlib"
|
||||||
version = "2.0.4"
|
version = "2.0.4"
|
||||||
@@ -554,7 +568,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -996,6 +1010,15 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
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]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1003,7 +1026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1017,6 +1040,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1234,18 +1263,6 @@ dependencies = [
|
|||||||
"x11",
|
"x11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gemmatrans-client"
|
|
||||||
version = "0.1.2"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tauri",
|
|
||||||
"tauri-build",
|
|
||||||
"tauri-plugin-http",
|
|
||||||
"tauri-plugin-opener",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -1609,6 +1626,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2156,6 +2189,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2465,6 +2515,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3150,16 +3244,19 @@ dependencies = [
|
|||||||
"cookie_store 0.22.1",
|
"cookie_store 0.22.1",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3170,13 +3267,16 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
@@ -3211,7 +3311,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.5.0",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3313,6 +3413,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3370,6 +3479,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -4316,6 +4448,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -4676,6 +4818,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4838,6 +4986,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gemmatrans-client"
|
name = "ai-translate-client"
|
||||||
version = "0.1.2"
|
version = "0.2.1"
|
||||||
description = "A Tauri App"
|
description = "A client using AI models to translate"
|
||||||
authors = ["you"]
|
authors = ["Julian"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# 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
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
# 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
|
# 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"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
@@ -23,4 +23,6 @@ tauri-plugin-opener = "2"
|
|||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
async fn translate(
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -9,7 +95,7 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.invoke_handler(tauri::generate_handler![translate])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
gemmatrans_client_lib::run()
|
ai_translate_client_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "gemmatrans-client",
|
"productName": "ai-translate-client",
|
||||||
"version": "0.1.2",
|
"version": "0.2.1",
|
||||||
"identifier": "top.volan.gemmatrans-client",
|
"identifier": "top.volan.ai-translate-client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "gemmatrans-client",
|
"title": "AI 翻译客户端",
|
||||||
"width": 1400,
|
"width": 1400,
|
||||||
"height": 900
|
"height": 900
|
||||||
}
|
}
|
||||||
|
|||||||
377
src/App.vue
@@ -12,10 +12,23 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Sun,
|
Sun,
|
||||||
Moon
|
Moon,
|
||||||
|
User,
|
||||||
|
Type,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Play
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
import { fetch } from '@tauri-apps/plugin-http';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useSettingsStore, LANGUAGES, DEFAULT_TEMPLATE } from './stores/settings';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import {
|
||||||
|
useSettingsStore,
|
||||||
|
LANGUAGES,
|
||||||
|
DEFAULT_TEMPLATE,
|
||||||
|
SPEAKER_IDENTITY_OPTIONS,
|
||||||
|
TONE_REGISTER_OPTIONS,
|
||||||
|
type ApiProfile
|
||||||
|
} 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';
|
||||||
@@ -41,23 +54,62 @@ const toggleTheme = () => {
|
|||||||
|
|
||||||
const view = ref<'translate' | 'settings' | 'logs'>('translate');
|
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
|
// 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) => {
|
||||||
@@ -81,6 +133,20 @@ const targetText = ref('');
|
|||||||
const isTranslating = ref(false);
|
const isTranslating = ref(false);
|
||||||
const showCopyFeedback = ref(false);
|
const showCopyFeedback = 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
|
// Language Selection
|
||||||
const sourceLangCode = computed({
|
const sourceLangCode = computed({
|
||||||
get: () => settings.sourceLang.code,
|
get: () => settings.sourceLang.code,
|
||||||
@@ -101,6 +167,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 };
|
||||||
@@ -135,61 +209,34 @@ 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 = {
|
||||||
model: settings.modelName,
|
model: settings.modelName,
|
||||||
prompt: prompt,
|
messages: [
|
||||||
|
{ role: "user", content: prompt }
|
||||||
|
],
|
||||||
stream: settings.enableStreaming
|
stream: settings.enableStreaming
|
||||||
};
|
};
|
||||||
|
|
||||||
settings.addLog('request', requestBody);
|
settings.addLog('request', requestBody);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${settings.ollamaApiAddress}/api/generate`, {
|
const response = await invoke<string>('translate', {
|
||||||
method: 'POST',
|
apiAddress: settings.apiBaseUrl,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
apiKey: settings.apiKey,
|
||||||
body: JSON.stringify(requestBody)
|
payload: requestBody
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
// For non-streaming, response is returned as string
|
||||||
const errorText = await response.text();
|
if (!settings.enableStreaming) {
|
||||||
settings.addLog('error', { status: response.status, text: errorText });
|
targetText.value = response;
|
||||||
throw new Error(`API error (${response.status}): ${errorText || response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
settings.addLog('response', 'Translation completed');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
const errorMsg = String(err);
|
||||||
settings.addLog('error', errorMsg);
|
settings.addLog('error', errorMsg);
|
||||||
targetText.value = `Error: ${errorMsg}`;
|
targetText.value = `Error: ${errorMsg}`;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -199,12 +246,12 @@ const translate = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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-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 -->
|
||||||
<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-white dark:bg-slate-900 flex items-center justify-between px-6 shrink-0 sticky top-0 z-10 shadow-sm">
|
||||||
<div class="flex items-center gap-2 cursor-pointer group" @click="view = 'translate'">
|
<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" />
|
<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>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -232,14 +279,14 @@ const translate = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 flex overflow-hidden">
|
<main class="flex-1 flex overflow-hidden min-h-0 relative">
|
||||||
<!-- Translation View -->
|
<!-- 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 dark:bg-slate-900 overflow-hidden h-full">
|
||||||
<!-- Source Pane -->
|
<!-- Source Pane -->
|
||||||
<div class="flex-1 flex flex-col min-h-0 relative">
|
<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-50/50 dark:bg-slate-800/30 relative z-40">
|
<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 shrink-0">
|
||||||
<!-- Custom Source Dropdown -->
|
<!-- Custom Source Dropdown -->
|
||||||
<div class="relative lang-dropdown min-w-[120px]">
|
<div class="relative lang-dropdown min-w-30">
|
||||||
<button
|
<button
|
||||||
@click.stop="toggleDropdown('source')"
|
@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"
|
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 +306,7 @@ const translate = async () => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="sourceDropdownOpen"
|
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
|
<button
|
||||||
v-for="lang in LANGUAGES"
|
v-for="lang in LANGUAGES"
|
||||||
@@ -289,9 +336,9 @@ const translate = async () => {
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="sourceText"
|
v-model="sourceText"
|
||||||
placeholder="请输入待翻译内容..."
|
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"
|
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>
|
></textarea>
|
||||||
<div class="p-4 border-t dark:border-slate-800 flex justify-end">
|
<div class="p-4 border-t dark:border-slate-800 flex justify-end shrink-0">
|
||||||
<button
|
<button
|
||||||
@click="translate"
|
@click="translate"
|
||||||
:disabled="isTranslating || !sourceText.trim()"
|
:disabled="isTranslating || !sourceText.trim()"
|
||||||
@@ -305,10 +352,10 @@ const translate = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Target Pane -->
|
<!-- 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-1 flex flex-col min-h-0 bg-slate-50/30 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-50/50 dark:bg-slate-800/30 relative z-40">
|
<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 shrink-0">
|
||||||
<!-- Custom Target Dropdown -->
|
<!-- Custom Target Dropdown -->
|
||||||
<div class="relative lang-dropdown min-w-[120px]">
|
<div class="relative lang-dropdown min-w-30">
|
||||||
<button
|
<button
|
||||||
@click.stop="toggleDropdown('target')"
|
@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"
|
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 +392,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" />
|
||||||
@@ -352,7 +487,7 @@ const translate = async () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<template v-if="targetText">
|
||||||
{{ targetText }}
|
{{ targetText }}
|
||||||
</template>
|
</template>
|
||||||
@@ -362,32 +497,113 @@ const translate = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings View -->
|
<!-- 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-50 dark:bg-slate-950 p-6 md:p-10 min-h-0">
|
||||||
<div class="max-w-2xl mx-auto space-y-8">
|
<div class="max-w-2xl mx-auto space-y-8">
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-4">API 配置</h2>
|
<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-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-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="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-white dark:bg-slate-900 rounded-xl shadow-sm border dark:border-slate-800 p-6 space-y-4">
|
<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">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Ollama API 地址</label>
|
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">API Base URL</label>
|
||||||
<input
|
<input
|
||||||
v-model="settings.ollamaApiAddress"
|
v-model="settings.apiBaseUrl"
|
||||||
type="text"
|
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"
|
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>
|
||||||
<div class="space-y-2">
|
<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
|
<input
|
||||||
v-model="settings.modelName"
|
v-model="settings.modelName"
|
||||||
type="text"
|
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"
|
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>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p class="text-xs text-slate-500 dark:text-slate-500">在生成时即时渲染文本</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -420,7 +636,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>
|
||||||
@@ -429,7 +645,7 @@ const translate = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs View -->
|
<!-- 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-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="max-w-3xl mx-auto space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<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>
|
||||||
@@ -461,7 +677,7 @@ const translate = async () => {
|
|||||||
)"
|
)"
|
||||||
>{{ log.type === 'request' ? '请求' : log.type === 'response' ? '响应' : '错误' }}</span>
|
>{{ log.type === 'request' ? '请求' : log.type === 'response' ? '响应' : '错误' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<pre class="bg-slate-50 dark:bg-slate-800/50 p-3 rounded-lg overflow-x-auto text-slate-600 dark:text-slate-300 max-h-48 leading-relaxed shadow-inner border border-slate-100 dark:border-slate-700">{{ typeof log.content === 'object' ? JSON.stringify(log.content, null, 2) : log.content }}</pre>
|
<pre class="bg-slate-50 dark:bg-slate-800/50 p-3 rounded-lg overflow-x-auto text-slate-600 dark:text-slate-300 max-h-56 leading-relaxed shadow-inner border border-slate-100 dark:border-slate-700">{{ typeof log.content === 'object' ? JSON.stringify(log.content, null, 2) : log.content }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,12 +687,11 @@ const translate = async () => {
|
|||||||
<!-- Footer -->
|
<!-- 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-100 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">
|
<div class="text-[10px] text-slate-400 dark:text-slate-500">
|
||||||
{{ settings.ollamaApiAddress }}
|
{{ settings.apiBaseUrl }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-slate-400 dark:text-slate-500">
|
<div class="text-[10px] text-slate-400 dark:text-slate-500">
|
||||||
GemmaTrans v{{ pkg.version }}
|
Client v{{ pkg.version }}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -3,38 +3,71 @@ import { defineStore } from 'pinia';
|
|||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
|
||||||
export interface Language {
|
export interface Language {
|
||||||
displayName: string; // UI 显示的中文名,如 "英语(英国)"
|
displayName: string; // UI 显示的中文名,如 "英语(英国)"
|
||||||
englishName: string; // 文件中的第二列,用于 {SOURCE_LANG}
|
englishName: string; // 文件中的第二列,用于 {SOURCE_LANG}
|
||||||
code: string; // 文件中的第一列,用于 {SOURCE_CODE}
|
code: string; // 文件中的第一列,用于 {SOURCE_CODE}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LANGUAGES: Language[] = [
|
export const LANGUAGES: Language[] = [
|
||||||
{ displayName: '中文(简体)', englishName: 'Simplified Chinese', code: 'zh-Hans' },
|
{ displayName: '中文(简体)', englishName: 'Simplified Chinese', code: 'zh-Hans' },
|
||||||
{ displayName: '中文(繁体)', englishName: 'Traditional Chinese', code: 'zh-Hant' },
|
{ displayName: '中文(繁体)', englishName: 'Traditional Chinese', code: 'zh-Hant' },
|
||||||
{ displayName: '英语', englishName: 'English', code: 'en' },
|
{ 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: 'Japanese', code: 'ja' },
|
||||||
{ displayName: '韩语', englishName: 'Korean', code: 'ko' },
|
{ displayName: '韩语', englishName: 'Korean', code: 'ko' },
|
||||||
{ displayName: '法语', englishName: 'French', code: 'fr' },
|
{ displayName: '法语', englishName: 'French', code: 'fr' },
|
||||||
{ displayName: '德语', englishName: 'German', code: 'de' },
|
{ displayName: '德语', englishName: 'German', code: 'de' },
|
||||||
{ displayName: '西班牙语', englishName: 'Spanish', code: 'es' },
|
|
||||||
{ displayName: '意大利语', englishName: 'Italian', code: 'it' },
|
{ displayName: '意大利语', englishName: 'Italian', code: 'it' },
|
||||||
{ displayName: '俄语', englishName: 'Russian', code: 'ru' },
|
{ displayName: '俄语', englishName: 'Russian', code: 'ru' },
|
||||||
{ displayName: '葡萄牙语', englishName: 'Portuguese', code: 'pt' },
|
|
||||||
{ displayName: '越南语', englishName: 'Vietnamese', code: 'vi' },
|
{ displayName: '越南语', englishName: 'Vietnamese', code: 'vi' },
|
||||||
{ displayName: '泰语', englishName: 'Thai', code: 'th' },
|
{ displayName: '泰语', englishName: 'Thai', code: 'th' },
|
||||||
{ 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}:
|
||||||
|
|
||||||
|
|
||||||
{TEXT}`;
|
{TEXT}`;
|
||||||
|
|
||||||
|
export interface ApiProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
modelName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
const isDark = useLocalStorage('is-dark', false);
|
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 modelName = useLocalStorage('model-name', 'translategemma:12b');
|
||||||
|
const profiles = useLocalStorage<ApiProfile[]>('api-profiles', []);
|
||||||
const enableStreaming = useLocalStorage('enable-streaming', true);
|
const enableStreaming = useLocalStorage('enable-streaming', true);
|
||||||
const systemPromptTemplate = useLocalStorage('system-prompt-template', DEFAULT_TEMPLATE);
|
const systemPromptTemplate = useLocalStorage('system-prompt-template', DEFAULT_TEMPLATE);
|
||||||
|
|
||||||
@@ -42,6 +75,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[2].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) => {
|
||||||
@@ -58,12 +94,16 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isDark,
|
isDark,
|
||||||
ollamaApiAddress,
|
apiBaseUrl,
|
||||||
|
apiKey,
|
||||||
modelName,
|
modelName,
|
||||||
|
profiles,
|
||||||
enableStreaming,
|
enableStreaming,
|
||||||
systemPromptTemplate,
|
systemPromptTemplate,
|
||||||
sourceLang,
|
sourceLang,
|
||||||
targetLang,
|
targetLang,
|
||||||
|
speakerIdentity,
|
||||||
|
toneRegister,
|
||||||
logs,
|
logs,
|
||||||
addLog
|
addLog
|
||||||
};
|
};
|
||||||
|
|||||||