Skip to content

Commit 2e3eae4

Browse files
authored
Merge pull request #242 from Yoko-0x0/feature/lmstudio
feat: Add LMStudio API integration with configuration and UI support
2 parents a8ea73c + e1eb1e8 commit 2e3eae4

6 files changed

Lines changed: 480 additions & 5 deletions

File tree

src/apis/ModelsApiService.cs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using System.Net.Http;
2+
using System.Text.Json;
3+
using LiveCaptionsTranslator.utils;
4+
5+
namespace LiveCaptionsTranslator.apis
6+
{
7+
/// <summary>
8+
/// Servicio para obtener listas de modelos desde APIs compatibles (LMStudio, Ollama, etc.)
9+
/// </summary>
10+
public static class ModelsApiService
11+
{
12+
private static readonly HttpClient client = new HttpClient()
13+
{
14+
Timeout = TimeSpan.FromSeconds(10)
15+
};
16+
17+
/// <summary>
18+
/// APIs que soportan obtener modelos desde un endpoint.
19+
/// </summary>
20+
public static readonly List<string> APIs_WITH_MODELS_ENDPOINT = new()
21+
{
22+
"LMStudio",
23+
"Ollama"
24+
};
25+
26+
/// <summary>
27+
/// Obtiene la URL del endpoint de modelos para una API.
28+
/// </summary>
29+
public static string GetModelsEndpoint(string apiName, string baseUrl)
30+
{
31+
return apiName switch
32+
{
33+
"LMStudio" => TextUtil.NormalizeUrl(baseUrl) + "/models",
34+
"Ollama" => TextUtil.NormalizeUrl(baseUrl) + "/api/tags",
35+
_ => null
36+
};
37+
}
38+
39+
/// <summary>
40+
/// Obtiene la lista de modelos disponibles desde la API.
41+
/// </summary>
42+
/// <param name="apiName">Nombre de la API (LMStudio, Ollama, etc.)</param>
43+
/// <param name="baseUrl">URL base de la API</param>
44+
/// <returns>Lista de identificadores de modelos para usar en el chat</returns>
45+
public static async Task<List<ModelInfo>> FetchModelsAsync(string apiName, string baseUrl, CancellationToken token = default)
46+
{
47+
string endpoint = GetModelsEndpoint(apiName, baseUrl);
48+
if (string.IsNullOrEmpty(endpoint))
49+
return new List<ModelInfo>();
50+
51+
try
52+
{
53+
var response = await client.GetAsync(endpoint, token);
54+
if (!response.IsSuccessStatusCode)
55+
return new List<ModelInfo>();
56+
57+
string json = await response.Content.ReadAsStringAsync(token);
58+
59+
return apiName switch
60+
{
61+
"LMStudio" => ParseLMStudioModels(json),
62+
"Ollama" => ParseOllamaModels(json),
63+
_ => new List<ModelInfo>()
64+
};
65+
}
66+
catch
67+
{
68+
return new List<ModelInfo>();
69+
}
70+
}
71+
72+
public class ModelInfo
73+
{
74+
public string Id { get; set; }
75+
public string DisplayName { get; set; }
76+
}
77+
78+
private static List<ModelInfo> ParseLMStudioModels(string json)
79+
{
80+
var result = new List<ModelInfo>();
81+
try
82+
{
83+
using var doc = JsonDocument.Parse(json);
84+
var root = doc.RootElement;
85+
86+
if (!root.TryGetProperty("models", out var modelsArray))
87+
return result;
88+
89+
foreach (var model in modelsArray.EnumerateArray())
90+
{
91+
string type = model.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null;
92+
if (type != "llm")
93+
continue;
94+
95+
string key = model.TryGetProperty("key", out var keyProp) ? keyProp.GetString() : null;
96+
if (string.IsNullOrEmpty(key))
97+
continue;
98+
99+
string displayName = model.TryGetProperty("display_name", out var dnProp) ? dnProp.GetString() : key;
100+
101+
result.Add(new ModelInfo { Id = key, DisplayName = displayName ?? key });
102+
}
103+
}
104+
catch { }
105+
106+
return result;
107+
}
108+
109+
private static List<ModelInfo> ParseOllamaModels(string json)
110+
{
111+
var result = new List<ModelInfo>();
112+
try
113+
{
114+
using var doc = JsonDocument.Parse(json);
115+
var root = doc.RootElement;
116+
117+
if (!root.TryGetProperty("models", out var modelsArray))
118+
return result;
119+
120+
foreach (var model in modelsArray.EnumerateArray())
121+
{
122+
string name = model.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
123+
if (string.IsNullOrEmpty(name))
124+
continue;
125+
126+
result.Add(new ModelInfo { Id = name, DisplayName = name });
127+
}
128+
}
129+
catch { }
130+
131+
return result;
132+
}
133+
}
134+
}

src/apis/TranslateAPI.cs

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Net;
1+
using System.Net;
22
using System.Net.Http;
33
using System.Text;
44
using System.Text.Json;
@@ -23,6 +23,7 @@ public static readonly Dictionary<string, Func<string, CancellationToken, Task<s
2323
{ "Google2", Google2 },
2424
{ "Ollama", Ollama },
2525
{ "OpenAI", OpenAI },
26+
{ "LMStudio", LMStudio },
2627
{ "DeepL", DeepL },
2728
{ "OpenRouter", OpenRouter },
2829
{ "Youdao", Youdao },
@@ -32,7 +33,7 @@ public static readonly Dictionary<string, Func<string, CancellationToken, Task<s
3233
};
3334
public static readonly List<string> LLM_BASED_APIS = new()
3435
{
35-
"Ollama", "OpenAI", "OpenRouter"
36+
"Ollama", "OpenAI", "OpenRouter", "LMStudio"
3637
};
3738
public static readonly List<string> NO_CONFIG_APIS = new()
3839
{
@@ -191,6 +192,92 @@ public static async Task<string> Ollama(string text, CancellationToken token = d
191192
return $"[ERROR] Translation Failed: HTTP Error - {response.StatusCode}";
192193
}
193194

195+
public static async Task<string> LMStudio(string text, CancellationToken token = default)
196+
{
197+
var config = Translator.Setting["LMStudio"] as LMStudioConfig;
198+
string language = LMStudioConfig.SupportedLanguages.TryGetValue(
199+
Translator.Setting.TargetLanguage, out var langValue) ? langValue : Translator.Setting.TargetLanguage;
200+
string apiUrl = TextUtil.NormalizeUrl(config.ApiUrl) + "/chat";
201+
202+
string systemPrompt = string.Format(Prompt, language);
203+
204+
// Build input with optional context
205+
string input = $"🔤 {text} 🔤";
206+
if (Translator.Setting.ContextAware)
207+
{
208+
var contextLines = new List<string>();
209+
foreach (var entry in Translator.Caption.AwareContexts)
210+
{
211+
string translatedText = entry.TranslatedText;
212+
if (translatedText.Contains("[ERROR]") || translatedText.Contains("[WARNING]"))
213+
continue;
214+
translatedText = RegexPatterns.NoticePrefix().Replace(translatedText, "");
215+
contextLines.Add($"🔤 {entry.SourceText} 🔤 → {translatedText}");
216+
}
217+
if (contextLines.Count > 0)
218+
input = string.Join("\n", contextLines) + "\n" + input;
219+
}
220+
221+
var requestData = new
222+
{
223+
model = config.ModelName,
224+
system_prompt = systemPrompt,
225+
input = input,
226+
temperature = config.Temperature
227+
};
228+
229+
string jsonContent = JsonSerializer.Serialize(requestData);
230+
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
231+
client.DefaultRequestHeaders.Clear();
232+
233+
HttpResponseMessage response;
234+
try
235+
{
236+
response = await client.PostAsync(apiUrl, content, token);
237+
}
238+
catch (OperationCanceledException ex)
239+
{
240+
if (ex.Message.StartsWith("The request"))
241+
return $"[ERROR] Translation Failed: The request was canceled due to timeout (> 8 seconds), " +
242+
$"please use a faster API or check network connection.";
243+
throw;
244+
}
245+
catch (Exception ex)
246+
{
247+
return $"[ERROR] Translation Failed: {ex.Message}";
248+
}
249+
250+
if (response.IsSuccessStatusCode)
251+
{
252+
string responseString = await response.Content.ReadAsStringAsync();
253+
using var doc = JsonDocument.Parse(responseString);
254+
var root = doc.RootElement;
255+
256+
// LMStudio native /api/v1/chat response:
257+
// { "output": [ { "type": "message", "content": "..." }, ... ] }
258+
if (root.TryGetProperty("output", out var outputArray) &&
259+
outputArray.ValueKind == JsonValueKind.Array)
260+
{
261+
foreach (var item in outputArray.EnumerateArray())
262+
{
263+
if (item.TryGetProperty("type", out var typeProp) &&
264+
typeProp.GetString() == "message" &&
265+
item.TryGetProperty("content", out var contentProp))
266+
{
267+
return RegexPatterns.ModelThinking().Replace(contentProp.GetString() ?? "", "");
268+
}
269+
}
270+
}
271+
272+
return "[ERROR] Translation Failed: Unexpected response format";
273+
}
274+
else
275+
{
276+
string body = await response.Content.ReadAsStringAsync();
277+
return $"[ERROR] Translation Failed: HTTP Error - {response.StatusCode}: {body}";
278+
}
279+
}
280+
194281
public static async Task<string> OpenRouter(string text, CancellationToken token = default)
195282
{
196283
var config = Translator.Setting["OpenRouter"] as OpenRouterConfig;

src/models/Setting.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.ComponentModel;
1+
using System.ComponentModel;
22
using System.IO;
33
using System.Runtime.CompilerServices;
44
using System.Text.Json;
@@ -196,6 +196,7 @@ public Setting()
196196
{ "Google2", [new TranslateAPIConfig()] },
197197
{ "Ollama", [new OllamaConfig()] },
198198
{ "OpenAI", [new OpenAIConfig()] },
199+
{ "LMStudio", [new LMStudioConfig()] },
199200
{ "OpenRouter", [new OpenRouterConfig()] },
200201
{ "DeepL", [new DeepLConfig()] },
201202
{ "Youdao", [new YoudaoConfig()] },
@@ -209,6 +210,7 @@ public Setting()
209210
{ "Google2", 0 },
210211
{ "Ollama", 0 },
211212
{ "OpenAI", 0 },
213+
{ "LMStudio", 0 },
212214
{ "OpenRouter", 0 },
213215
{ "DeepL", 0 },
214216
{ "Youdao", 0 },
@@ -265,6 +267,13 @@ public static Setting Load(string jsonPath)
265267
setting.Configs[key] = [new TranslateAPIConfig()];
266268
}
267269

270+
// Ensure ConfigIndices has all keys (for upgrades from older setting.json)
271+
foreach (string key in TranslateAPI.TRANSLATE_FUNCTIONS.Keys)
272+
{
273+
if (!setting.ConfigIndices.ContainsKey(key))
274+
setting.ConfigIndices[key] = 0;
275+
}
276+
268277
return setting;
269278
}
270279

src/models/TranslateAPIConfig.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.ComponentModel;
1+
using System.ComponentModel;
22
using System.Runtime.CompilerServices;
33
using System.Text.Json.Serialization;
44

@@ -164,6 +164,27 @@ public string ApiKey
164164
}
165165
}
166166

167+
public class LMStudioConfig : BaseLLMConfig
168+
{
169+
public class Response
170+
{
171+
public string model { get; set; }
172+
public string output { get; set; }
173+
}
174+
175+
private string apiUrl = "http://localhost:1234/api/v1";
176+
177+
public string ApiUrl
178+
{
179+
get => apiUrl;
180+
set
181+
{
182+
apiUrl = value;
183+
OnPropertyChanged("ApiUrl");
184+
}
185+
}
186+
}
187+
167188
public class DeepLConfig : TranslateAPIConfig
168189
{
169190
[JsonIgnore]

0 commit comments

Comments
 (0)