210 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|