diff --git a/Apps/AIME/AIMEInputController+Polish.swift b/Apps/AIME/AIMEInputController+Polish.swift index d72e058..2b054ca 100644 --- a/Apps/AIME/AIMEInputController+Polish.swift +++ b/Apps/AIME/AIMEInputController+Polish.swift @@ -114,7 +114,7 @@ extension AIMEInputController { guard features.aiRemoteAllowed, let url = URL(string: features.aiBaseURL) else { return engine.showStatus("未允许把文字发送到自备接口:设置 › AI 助手") } - provider = OpenAICompatibleProvider(baseURL: url, model: features.aiModel) + provider = OpenAICompatibleProvider(baseURL: url, model: features.aiModel, extraFieldsJSON: features.aiExtraJSON) } else { provider = FoundationModelsProvider() } diff --git a/Apps/AIMESettings/Views/AIAssistantView.swift b/Apps/AIMESettings/Views/AIAssistantView.swift index ad709f9..7f98efa 100644 --- a/Apps/AIMESettings/Views/AIAssistantView.swift +++ b/Apps/AIMESettings/Views/AIAssistantView.swift @@ -62,6 +62,8 @@ struct AIAssistantView: View { @AppStorage("ai.provider") private var providerID = "apple" @AppStorage("ai.baseURL") private var baseURL = "https://api.openai.com/v1" @AppStorage("ai.model") private var modelName = "gpt-5-mini" + /// Extra JSON object merged into every chat/completions request body. + @AppStorage("ai.extraJSON") private var extraJSON = "" @State private var apiKey = "" @State private var availability: AIAvailability? /// Outcome of the last connection check (OpenAI-compatible endpoint). @@ -101,6 +103,58 @@ struct AIAssistantView: View { } } + /// Each vendor's way of turning the model's thinking off. One click merges + /// the fields into the custom-JSON box; nothing else is touched. + struct ThinkingPreset { + var title: String + var hint: String + var fields: [String: Any] + } + + static let thinkingPresets: [ThinkingPreset] = [ + .init(title: "DeepSeek", hint: "DeepSeek:官方 thinking 参数", fields: ["thinking": ["type": "disabled"]]), + .init(title: "Gemini", hint: "Gemini(OpenAI 兼容接口)", fields: ["reasoning_effort": "none"]), + .init(title: "GLM", hint: "智谱 GLM", fields: ["thinking": ["type": "disabled"]]), + .init(title: "Kimi", hint: "Kimi / Moonshot:官方 thinking 参数", fields: ["thinking": ["type": "disabled"]]), + .init(title: "OpenAI", hint: "OpenAI:仅推理模型(如 gpt-5、o 系列)支持;非推理模型带此字段会报错", fields: ["reasoning_effort": "none"]), + .init(title: "OpenRouter", hint: "OpenRouter 统一参数,适用于 Claude 等模型", fields: ["reasoning": ["effort": "none"]]), + .init(title: "Qwen", hint: "通义 Qwen / vLLM 部署", fields: ["enable_thinking": false]), + ] + + /// Parses the box as a JSON object. nil = empty or not a valid JSON object. + private var extraJSONObject: [String: Any]? { + let trimmed = extraJSON.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + /// Live verdict under the box: field count when valid, a hint when not. + @ViewBuilder private var extraJSONStatus: some View { + let trimmed = extraJSON.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + EmptyView() + } else if let object = extraJSONObject { + Label("JSON 有效,将合并 \(object.count) 个字段", systemImage: "checkmark.circle.fill") + .foregroundStyle(Theme.success) + } else { + Label("不是合法的 JSON 对象,不会被发送", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(Theme.warning) + } + } + + /// Merges a preset's fields into the box. Merges into the existing object + /// when it parses; non-empty invalid JSON is left alone so the user does + /// not lose a half-written object. + private func mergeFields(_ fields: [String: Any]) { + let trimmed = extraJSON.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, extraJSONObject == nil { return } + var object = extraJSONObject ?? [:] + for (key, value) in fields { object[key] = value } + guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) else { return } + extraJSON = text + } + private func saveKeyAndVerify() { let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return } @@ -120,7 +174,7 @@ struct AIAssistantView: View { } check = .running do { - check = .passed(try await OpenAICompatibleProvider(baseURL: url, model: modelName).verify()) + check = .passed(try await OpenAICompatibleProvider(baseURL: url, model: modelName, extraFieldsJSON: extraJSON).verify()) } catch let error as AIError { check = .failed(error.description) } catch { @@ -130,7 +184,7 @@ struct AIAssistantView: View { private var provider: any AIProvider { if providerID == "openai", let url = URL(string: baseURL) { - return OpenAICompatibleProvider(baseURL: url, model: modelName) + return OpenAICompatibleProvider(baseURL: url, model: modelName, extraFieldsJSON: extraJSON) } return FoundationModelsProvider() } @@ -169,6 +223,40 @@ struct AIAssistantView: View { Button("保存并验证", action: saveKeyAndVerify) .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + VStack(alignment: .leading, spacing: 6) { + Text("自定义请求体") + Text("顶层字段合并进每次请求的 body,可覆盖 temperature、max_tokens 等;model、messages、stream 由程序控制。") + .font(.caption).foregroundStyle(.secondary) + TextEditor(text: $extraJSON) + .font(.caption.monospaced()) + .frame(minHeight: 60, maxHeight: 140) + .overlay(alignment: .topLeading) { + if extraJSON.isEmpty { + Text("{\"thinking\": {\"type\": \"disabled\"}}") + .font(.caption.monospaced()) + .foregroundStyle(.tertiary) + .padding(.top, 8).padding(.leading, 5) + .allowsHitTesting(false) + } + } + HStack(spacing: 6) { + extraJSONStatus + } + .font(.caption) + } + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("关闭思考").foregroundStyle(.secondary) + ForEach(Self.thinkingPresets, id: \.title) { preset in + Button(preset.title) { mergeFields(preset.fields) } + .controlSize(.small) + .help(preset.title) + } + } + Text("\(Self.thinkingPresets.map(\.title).joined(separator: "、")) — 按各家官方文档填入对应的关闭思考参数。") + .font(.caption2).foregroundStyle(.tertiary) + } + .font(.caption) } LabeledContent("状态") { if providerID == "openai" { @@ -188,13 +276,14 @@ struct AIAssistantView: View { } .task(id: providerID) { availability = await provider.availability() } // Address or model changed: the previous check no longer says anything. - .onChange(of: [baseURL, modelName]) { check = .idle } - .onChange(of: [providerID, baseURL, modelName], initial: true) { + .onChange(of: [baseURL, modelName, extraJSON]) { check = .idle } + .onChange(of: [providerID, baseURL, modelName, extraJSON], initial: true) { // The input method reads the provider from aime/features.json. model.updateFeatures { $0.aiProvider = providerID $0.aiBaseURL = baseURL $0.aiModel = modelName + $0.aiExtraJSON = extraJSON } } diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 9511bfc..39cd7a6 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -12,6 +12,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] +### Added + +- Settings › AI Assistant › OpenAI-compatible endpoint: a new Custom request body field accepts any JSON object whose top-level fields are merged into every request body (model, messages and stream stay under program control). A row of “disable thinking” buttons fills in the right parameter for DeepSeek, Gemini, GLM, Kimi, OpenAI, OpenRouter and Qwen. + ## [0.1.7] - 2026-10-09 > Stable release · Developer ID signed · notarized by Apple diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3ff3b..c579158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ AIME 每个版本面向用户的变动都记在这里,官网「更新日志」 ## [Unreleased] +### 新增 + +- 设置 › AI 助手 › OpenAI 兼容接口:新增「自定义请求体」,可输入任意 JSON 对象,顶层字段会合并进每次请求的 body(model、messages、stream 由程序控制)。下方一排「关闭思考」按钮可一键填入 DeepSeek、Gemini、GLM、Kimi、OpenAI、OpenRouter、Qwen 各家的关闭思考参数。 + ## [0.1.7] - 2026-10-09 > 正式版 · Developer ID 签名 · 已通过 Apple 公证 diff --git a/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift b/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift index 8a8c10c..d4a4703 100644 --- a/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift +++ b/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift @@ -7,13 +7,18 @@ public struct OpenAICompatibleProvider: AIProvider { public var displayName: String { "OpenAI 兼容接口(\(model))" } public var baseURL: URL public var model: String + /// User-supplied JSON object merged into every request body (top-level keys + /// only; `model`, `messages` and `stream` are never overridden). Invalid JSON + /// is ignored. + public let extraFieldsJSON: String let credentials: CredentialStore let session: URLSession public init(baseURL: URL, model: String, keychainAccount: String = "openai-compatible", - credentials: CredentialStore? = nil, session: URLSession = .shared) { + credentials: CredentialStore? = nil, session: URLSession = .shared, extraFieldsJSON: String = "") { self.baseURL = baseURL self.model = model + self.extraFieldsJSON = extraFieldsJSON self.credentials = credentials ?? CredentialStore(account: keychainAccount) self.session = session } @@ -53,6 +58,9 @@ public struct OpenAICompatibleProvider: AIProvider { request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") var body: [String: Any] = ["model": model, "temperature": temperature, "messages": messages] if let maxTokens { body["max_tokens"] = maxTokens } + for (key, value) in Self.extraFields(from: extraFieldsJSON) where !Self.reservedKeys.contains(key) { + body[key] = value + } request.httpBody = try JSONSerialization.data(withJSONObject: body) let data: Data, response: URLResponse do { @@ -72,6 +80,20 @@ public struct OpenAICompatibleProvider: AIProvider { return (content, json["model"] as? String) } + /// Top-level keys the program always sets itself; the user's extra JSON can + /// never override them (`stream` included: the client cannot parse SSE). + static let reservedKeys: Set = ["model", "messages", "stream"] + + /// Parses the user's extra-fields JSON. Anything that is not a JSON object + /// (or is invalid) contributes nothing. + static func extraFields(from json: String) -> [String: Any] { + let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [:] } + return object + } + /// Plain-language reason for an HTTP error, so "524" is not all the user sees. static func explain(status: Int, body: String) -> String { let detail = body.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Packages/AIMEKit/Sources/AIMECore/Resources/catalog.json b/Packages/AIMEKit/Sources/AIMECore/Resources/catalog.json index f6fbfef..2ac10e7 100644 --- a/Packages/AIMEKit/Sources/AIMECore/Resources/catalog.json +++ b/Packages/AIMEKit/Sources/AIMECore/Resources/catalog.json @@ -1450,6 +1450,15 @@ "storage": "aime/update.json", "key": "receiveBeta", "doc": "docs/rime-config-reference.md#更新通道aime-应用设置" + }, + { + "id": "ai.extraJSON", + "title": "自定义请求体", + "type": "string", + "default": "", + "storage": "aime/features.json", + "key": "aiExtraJSON", + "doc": "docs/rime-config-reference.md#ai-助手aime-应用设置" } ] } diff --git a/Packages/AIMEKit/Sources/AIMECore/UsageStats.swift b/Packages/AIMEKit/Sources/AIMECore/UsageStats.swift index 1374551..dc2fa98 100644 --- a/Packages/AIMEKit/Sources/AIMECore/UsageStats.swift +++ b/Packages/AIMEKit/Sources/AIMECore/UsageStats.swift @@ -11,6 +11,9 @@ public struct AIMEFeatures: Sendable, Codable, Equatable { public var aiProvider = "apple" public var aiBaseURL = "https://api.openai.com/v1" public var aiModel = "gpt-5-mini" + /// Extra JSON object merged into every request body (`model`, `messages` + /// and `stream` stay under program control). Empty means no extra fields. + public var aiExtraJSON = "" /// Explicit consent to send the selected text to `aiBaseURL` when polishing. public var aiRemoteAllowed = false /// Small AIME button at the end of the candidate window (opens the quick menu). @@ -51,6 +54,7 @@ public struct AIMEFeatures: Sendable, Codable, Equatable { aiProvider = try container.decodeIfPresent(String.self, forKey: .aiProvider) ?? defaults.aiProvider aiBaseURL = try container.decodeIfPresent(String.self, forKey: .aiBaseURL) ?? defaults.aiBaseURL aiModel = try container.decodeIfPresent(String.self, forKey: .aiModel) ?? defaults.aiModel + aiExtraJSON = try container.decodeIfPresent(String.self, forKey: .aiExtraJSON) ?? defaults.aiExtraJSON aiRemoteAllowed = try container.decodeIfPresent(Bool.self, forKey: .aiRemoteAllowed) ?? defaults.aiRemoteAllowed panelMenuButton = try container.decodeIfPresent(Bool.self, forKey: .panelMenuButton) ?? defaults.panelMenuButton menuHoldKey = try container.decodeIfPresent(ModifierHold.Key.self, forKey: .menuHoldKey) ?? defaults.menuHoldKey diff --git a/Packages/AIMEKit/Tests/AIMEAITests/AITests.swift b/Packages/AIMEKit/Tests/AIMEAITests/AITests.swift index 8e2e9b3..b035155 100644 --- a/Packages/AIMEKit/Tests/AIMEAITests/AITests.swift +++ b/Packages/AIMEKit/Tests/AIMEAITests/AITests.swift @@ -3,6 +3,41 @@ import Testing @testable import AIMEAI @testable import AIMECore +/// Captures the request body of the next URLSession request and answers with a +/// canned chat/completions response, so tests can inspect what would be sent. +final class BodyCapturingProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var lastBody: [String: Any]? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + if let stream = request.httpBodyStream, + let data = try? Data(reading: stream) { + Self.lastBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + let json = #"{"choices":[{"message":{"content":"OK"}}],"model":"m"}"#.data(using: .utf8)! + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: json) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +private extension Data { + init(reading stream: InputStream) throws { + stream.open() + defer { stream.close() } + self.init() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let n = stream.read(&buffer, maxLength: buffer.count) + if n < 0 { throw stream.streamError ?? CocoaError(.coderReadCorrupt) } + if n == 0 { break } + append(contentsOf: buffer[0..