support generic openai api

This commit is contained in:
Julian Freeman
2026-02-23 18:33:13 -04:00
parent 8df295cbf5
commit f84ee6ced7
7 changed files with 77 additions and 32 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "gemmatrans-client", "name": "gemmatrans-client",
"private": true, "private": true,
"version": "0.2.0", "version": "0.2.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@@ -1251,7 +1251,7 @@ dependencies = [
[[package]] [[package]]
name = "gemmatrans-client" name = "gemmatrans-client"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"reqwest 0.12.28", "reqwest 0.12.28",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "gemmatrans-client" name = "gemmatrans-client"
version = "0.2.0" version = "0.2.1"
description = "A translategemma client" description = "A translategemma client"
authors = ["Julian"] authors = ["Julian"]
edition = "2021" edition = "2021"

View File

@@ -3,38 +3,61 @@ use serde::{Deserialize, Serialize};
use futures_util::StreamExt; use futures_util::StreamExt;
use reqwest::Client; use reqwest::Client;
#[derive(Serialize, Deserialize, Clone)]
struct Message {
role: String,
content: String,
}
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
struct TranslationPayload { struct TranslationPayload {
model: String, model: String,
prompt: String, messages: Vec<Message>,
stream: bool, stream: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct OllamaResponse { struct OpenAIResponse {
response: Option<String>, choices: Vec<Choice>,
// done: bool, }
#[derive(Deserialize)]
struct Choice {
message: Option<Message>,
delta: Option<Delta>,
}
#[derive(Deserialize)]
struct Delta {
content: Option<String>,
} }
#[tauri::command] #[tauri::command]
async fn translate( async fn translate(
app: AppHandle, app: AppHandle,
api_address: String, api_address: String,
api_key: String,
payload: TranslationPayload, payload: TranslationPayload,
) -> Result<String, String> { ) -> Result<String, String> {
let client = Client::new(); let client = Client::new();
let url = format!("{}/api/generate", api_address); // 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 res = client let mut request = client.post(&url).json(&payload);
.post(&url)
.json(&payload) if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {}", api_key));
}
let res = request
.send() .send()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if !payload.stream { if !payload.stream {
let data = res.json::<OllamaResponse>().await.map_err(|e| e.to_string())?; let data = res.json::<OpenAIResponse>().await.map_err(|e| e.to_string())?;
return Ok(data.response.unwrap_or_default()); 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 stream = res.bytes_stream();
@@ -44,13 +67,21 @@ async fn translate(
let chunk = item.map_err(|e| e.to_string())?; let chunk = item.map_err(|e| e.to_string())?;
let text = String::from_utf8_lossy(&chunk); let text = String::from_utf8_lossy(&chunk);
// Handle potential multiple JSON objects in one chunk
for line in text.lines() { for line in text.lines() {
if line.trim().is_empty() { continue; } let line = line.trim();
if let Ok(json) = serde_json::from_str::<OllamaResponse>(line) { if line.is_empty() { continue; }
if let Some(token) = json.response { if line == "data: [DONE]" { break; }
full_response.push_str(&token);
app.emit("translation-chunk", token).map_err(|e| e.to_string())?; 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())?;
}
}
}
} }
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "gemmatrans-client", "productName": "gemmatrans-client",
"version": "0.2.0", "version": "0.2.1",
"identifier": "top.volan.gemmatrans-client", "identifier": "top.volan.gemmatrans-client",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",

View File

@@ -181,7 +181,9 @@ const translate = async () => {
const requestBody = { const requestBody = {
model: settings.modelName, model: settings.modelName,
prompt: prompt, messages: [
{ role: "user", content: prompt }
],
stream: settings.enableStreaming stream: settings.enableStreaming
}; };
@@ -189,7 +191,8 @@ const translate = async () => {
try { try {
const response = await invoke<string>('translate', { const response = await invoke<string>('translate', {
apiAddress: settings.ollamaApiAddress, apiAddress: settings.apiBaseUrl,
apiKey: settings.apiKey,
payload: requestBody payload: requestBody
}); });
@@ -463,29 +466,38 @@ const translate = async () => {
<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 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">模型接口配置</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="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
@@ -569,7 +581,7 @@ 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">
Client v{{ pkg.version }} Client v{{ pkg.version }}

View File

@@ -56,7 +56,8 @@ Produce only the {TARGET_LANG} translation, without any additional explanations
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 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);
@@ -84,7 +85,8 @@ export const useSettingsStore = defineStore('settings', () => {
return { return {
isDark, isDark,
ollamaApiAddress, apiBaseUrl,
apiKey,
modelName, modelName,
enableStreaming, enableStreaming,
systemPromptTemplate, systemPromptTemplate,