blitztext-app-linux/BlitztextMac/Services/LLMService.swift
2026-05-23 07:28:46 +02:00

210 lines
6.8 KiB
Swift

import Foundation
enum LLMError: LocalizedError {
case notConfigured
case networkError(String)
case apiError(String)
case noContent
var errorDescription: String? {
switch self {
case .notConfigured:
return "OpenAI API Key fehlt. Bitte in den Einstellungen hinterlegen."
case .networkError(let msg):
return "Verbindungsproblem: \(msg)"
case .apiError(let msg):
return "Fehler von OpenAI: \(msg)"
case .noContent:
return "Keine Antwort erhalten. Bitte nochmal versuchen."
}
}
}
enum RewriteModel: String {
case fastEdit = "gpt-4o-mini"
case rageMode = "gpt-4o"
}
private struct OpenAIChatRequest: Encodable {
struct Message: Encodable {
let role: String
let content: String
}
let model: String
let messages: [Message]
let temperature: Double
}
private struct OpenAIChatResponse: Decodable {
struct Choice: Decodable {
struct Message: Decodable {
let content: String?
}
let message: Message?
}
let choices: [Choice]?
}
private struct OpenAIErrorResponse: Decodable {
struct APIError: Decodable {
let message: String?
}
let error: APIError?
}
enum LLMService {
private static let chatCompletionsURL = URL(string: "https://api.openai.com/v1/chat/completions")!
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.waitsForConnectivity = false
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = 45
configuration.timeoutIntervalForResource = 45
return URLSession(configuration: configuration)
}()
static func improve(
text: String,
settings: TextImprovementSettings,
model: RewriteModel = .fastEdit
) async throws -> String {
try await complete(
text: text,
systemPrompt: buildSystemPrompt(settings: settings),
model: model,
temperature: 0.3
)
}
static func dampfAblassen(
text: String,
systemPrompt: String,
model: RewriteModel = .rageMode
) async throws -> String {
try await complete(
text: text,
systemPrompt: systemPrompt,
model: model,
temperature: 0.4
)
}
static func addEmojis(
text: String,
settings: EmojiTextSettings,
model: RewriteModel = .fastEdit
) async throws -> String {
try await complete(
text: text,
systemPrompt: buildEmojiSystemPrompt(density: settings.emojiDensity),
model: model,
temperature: 0.3
)
}
private static func complete(
text: String,
systemPrompt: String,
model: RewriteModel,
temperature: Double
) async throws -> String {
guard let apiKey = KeychainService.load(key: .openAIAPIKey) else {
throw LLMError.notConfigured
}
let payload = OpenAIChatRequest(
model: model.rawValue,
messages: [
.init(role: "system", content: systemPrompt),
.init(role: "user", content: text),
],
temperature: temperature
)
var request = URLRequest(url: chatCompletionsURL)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 45
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw LLMError.networkError("Keine gültige Antwort")
}
guard httpResponse.statusCode == 200 else {
throw LLMError.apiError(openAIErrorMessage(from: data) ?? "Status \(httpResponse.statusCode)")
}
let result = try JSONDecoder().decode(OpenAIChatResponse.self, from: data)
guard let content = result.choices?.first?.message?.content,
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LLMError.noContent
}
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func openAIErrorMessage(from data: Data) -> String? {
(try? JSONDecoder().decode(OpenAIErrorResponse.self, from: data))?.error?.message
}
private static func buildEmojiSystemPrompt(density: EmojiTextSettings.EmojiDensity) -> String {
let densityInstruction: String
switch density {
case .wenig:
densityInstruction = "Setze nur vereinzelt Emojis ein, maximal 1-2 pro Absatz."
case .mittel:
densityInstruction = "Setze regelmaessig passende Emojis ein, etwa alle 1-2 Saetze."
case .viel:
densityInstruction = "Setze grosszuegig Emojis ein, gerne mehrere pro Satz."
}
return "Du erhaeltst ein gesprochenes Transkript. Gib den Text moeglichst originalgetreu zurueck, aber fuege passende Emojis ein. \(densityInstruction) Korrigiere offensichtliche Sprach- und Grammatikfehler. Behalte den Stil und die Bedeutung bei. Gib NUR den Text mit Emojis zurueck, keine Erklaerungen."
}
private static func buildSystemPrompt(settings: TextImprovementSettings) -> String {
if !settings.systemPrompt.isEmpty {
var prompt = settings.systemPrompt
if !settings.customTerms.isEmpty {
prompt += "\n\nWichtig: Diese Eigennamen und Fachbegriffe muessen exakt so geschrieben werden: \(settings.customTerms.joined(separator: ", "))"
}
return prompt
}
var prompt = """
Du bist ein Lektor und Schreibassistent. Verbessere den folgenden Text:
- Korrigiere Rechtschreibung und Grammatik
- Verbessere die Formulierung und den Lesefluss
- Behalte die urspruengliche Bedeutung bei
- Gib NUR den verbesserten Text zurueck, keine Erklaerungen
"""
switch settings.tone {
case .formal:
prompt += "\n- Verwende einen formellen, professionellen Ton"
case .neutral:
prompt += "\n- Verwende einen neutralen, klaren Ton"
case .casual:
prompt += "\n- Verwende einen lockeren, natuerlichen Ton"
}
if !settings.customTerms.isEmpty {
prompt += "\n\nWichtig: Diese Eigennamen und Fachbegriffe muessen exakt so geschrieben werden: \(settings.customTerms.joined(separator: ", "))"
}
if !settings.context.isEmpty {
prompt += "\n\nKontext: \(settings.context)"
}
return prompt
}
}