From a22edb42eeabb60ccd1ff3b61608cb40b835e34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=A2=E6=9D=A1?= Date: Sat, 10 Oct 2026 22:06:47 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20AI=20=E5=8A=A9=E6=89=8B=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E6=A8=A1=E5=9E=8B=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E8=AF=B7=E6=B1=82=E4=BD=93=20JSON=20?= =?UTF-8?q?=E4=B8=8E=E5=85=B3=E9=97=AD=E6=80=9D=E8=80=83=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E5=A1=AB=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AIMEFeatures 新增 aiExtraJSON 字段,持久化到 features.json - OpenAICompatibleProvider 支持 extraFieldsJSON,顶层字段合并进请求体 - model/messages 键由程序控制,不可被自定义 JSON 覆盖 - 设置界面新增多行 JSON 输入框,实时合法性提示 - 关闭思考快捷按钮(Claude/DeepSeek/Gemini/GLM/Kimi/OpenAI/Qwen) --- Apps/AIME/AIMEInputController+Polish.swift | 2 +- Apps/AIMESettings/Views/AIAssistantView.swift | 85 ++++++++++++++++++- .../AIMEAI/OpenAICompatibleProvider.swift | 19 ++++- .../AIMEKit/Sources/AIMECore/UsageStats.swift | 4 + .../AIMEKit/Tests/AIMEAITests/AITests.swift | 10 +++ 5 files changed, 114 insertions(+), 6 deletions(-) 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..c565d6a 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: "Claude", hint: "Claude(OpenRouter)", fields: ["reasoning": ["effort": "none"]]), + .init(title: "DeepSeek", hint: "DeepSeek / Qwen", fields: ["enable_thinking": false]), + .init(title: "Gemini", hint: "Gemini", fields: ["reasoning_effort": "none"]), + .init(title: "GLM", hint: "智谱 GLM", fields: ["thinking": ["type": "disabled"]]), + .init(title: "Kimi", hint: "Kimi / Moonshot", fields: ["enable_thinking": false]), + .init(title: "OpenAI", hint: "OpenAI", fields: ["reasoning_effort": "minimal"]), + .init(title: "Qwen", hint: "通义 Qwen", 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,28 @@ struct AIAssistantView: View { Button("保存并验证", action: saveKeyAndVerify) .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + VStack(alignment: .leading, spacing: 6) { + Text("自定义请求体") + TextField("{\"enable_thinking\": false}", text: $extraJSON, axis: .vertical) + .font(.caption.monospaced()) + .textFieldStyle(.roundedBorder) + .lineLimit(3...7) + HStack(spacing: 6) { + Text("关闭思考:").foregroundStyle(.secondary) + ForEach(Self.thinkingPresets, id: \.title) { preset in + Button(preset.title) { mergeFields(preset.fields) } + .controlSize(.small) + .help(preset.hint) + } + Spacer() + } + .font(.caption) + HStack(spacing: 6) { + extraJSONStatus + Text("顶层字段合并进每次请求的 body;model 与 messages 由程序控制,不会被覆盖。") + } + .font(.caption) + } } LabeledContent("状态") { if providerID == "openai" { @@ -188,13 +264,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/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift b/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift index 8a8c10c..8b79112 100644 --- a/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift +++ b/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift @@ -7,13 +7,17 @@ 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` and `messages` 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 +57,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 key != "model" && key != "messages" { + body[key] = value + } request.httpBody = try JSONSerialization.data(withJSONObject: body) let data: Data, response: URLResponse do { @@ -72,6 +79,16 @@ public struct OpenAICompatibleProvider: AIProvider { return (content, json["model"] as? String) } + /// 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/UsageStats.swift b/Packages/AIMEKit/Sources/AIMECore/UsageStats.swift index 1374551..3745f11 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` and `messages` + /// 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..7d8645c 100644 --- a/Packages/AIMEKit/Tests/AIMEAITests/AITests.swift +++ b/Packages/AIMEKit/Tests/AIMEAITests/AITests.swift @@ -79,6 +79,7 @@ struct PolishTests { @Test func featuresDecodeWithMissingKeys() throws { let features = try JSONDecoder().decode(AIMEFeatures.self, from: Data(#"{"usageStats":true}"#.utf8)) #expect(features.usageStats && !features.aiPolish && !features.aiRemoteAllowed && features.aiProvider == "apple") + #expect(features.aiExtraJSON.isEmpty) } } @@ -130,6 +131,15 @@ struct CredentialAndErrorTests { #expect(AIError.http(524, OpenAICompatibleProvider.explain(status: 524, body: "")).description.hasPrefix("HTTP 524:")) } + @Test func extraFieldsParseOnlyJSONObjects() { + #expect(OpenAICompatibleProvider.extraFields(from: "").isEmpty) + #expect(OpenAICompatibleProvider.extraFields(from: " ").isEmpty) + #expect(OpenAICompatibleProvider.extraFields(from: "not json").isEmpty) + #expect(OpenAICompatibleProvider.extraFields(from: "[1,2]").isEmpty) + let fields = OpenAICompatibleProvider.extraFields(from: #"{"enable_thinking":false,"n":1}"#) + #expect(fields["enable_thinking"] as? Bool == false && fields["n"] as? Int == 1) + } + @Test func missingKeyFailsBeforeAnyRequest() async { let url = FileManager.default.temporaryDirectory.appendingPathComponent("aime-cred-\(UUID().uuidString)/credentials.json") let provider = OpenAICompatibleProvider(baseURL: URL(string: "https://example.invalid/v1")!, model: "m", From f0f37d352e203463d29e4cccbd67ba37c8080f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=A2=E6=9D=A1?= Date: Sun, 11 Oct 2026 12:31:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?review:=20stream=20=E5=88=97=E5=85=A5?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E5=AD=97=E6=AE=B5=EF=BC=9B=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=80=9D=E8=80=83=E9=A2=84=E8=AE=BE=E4=B8=8E=E5=90=84=E5=AE=B6?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E6=96=87=E6=A1=A3=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model/messages/stream 均不可被自定义 JSON 覆盖,并补回归测试 - DeepSeek、Kimi 改用官方 thinking: {type: disabled} 参数 - Claude 按钮改名 OpenRouter(reasoning.effort 是 OpenRouter 统一参数) - OpenAI 预设改为 reasoning_effort: none,并提示仅推理模型适用 - CHANGELOG / catalog appSettings / 配置参考文档登记 aiExtraJSON - 说明文案写明可覆盖 temperature、max_tokens 等 --- Apps/AIMESettings/Views/AIAssistantView.swift | 14 ++--- CHANGELOG.en.md | 4 ++ CHANGELOG.md | 4 ++ .../AIMEAI/OpenAICompatibleProvider.swift | 9 ++- .../Sources/AIMECore/Resources/catalog.json | 9 +++ .../AIMEKit/Sources/AIMECore/UsageStats.swift | 4 +- .../AIMEKit/Tests/AIMEAITests/AITests.swift | 56 +++++++++++++++++++ docs/rime-config-reference.md | 8 +++ 8 files changed, 97 insertions(+), 11 deletions(-) diff --git a/Apps/AIMESettings/Views/AIAssistantView.swift b/Apps/AIMESettings/Views/AIAssistantView.swift index c565d6a..a26f6cb 100644 --- a/Apps/AIMESettings/Views/AIAssistantView.swift +++ b/Apps/AIMESettings/Views/AIAssistantView.swift @@ -112,13 +112,13 @@ struct AIAssistantView: View { } static let thinkingPresets: [ThinkingPreset] = [ - .init(title: "Claude", hint: "Claude(OpenRouter)", fields: ["reasoning": ["effort": "none"]]), - .init(title: "DeepSeek", hint: "DeepSeek / Qwen", fields: ["enable_thinking": false]), - .init(title: "Gemini", hint: "Gemini", fields: ["reasoning_effort": "none"]), + .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", fields: ["enable_thinking": false]), - .init(title: "OpenAI", hint: "OpenAI", fields: ["reasoning_effort": "minimal"]), - .init(title: "Qwen", hint: "通义 Qwen", fields: ["enable_thinking": false]), + .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. @@ -241,7 +241,7 @@ struct AIAssistantView: View { .font(.caption) HStack(spacing: 6) { extraJSONStatus - Text("顶层字段合并进每次请求的 body;model 与 messages 由程序控制,不会被覆盖。") + Text("顶层字段合并进每次请求的 body,可覆盖 temperature、max_tokens 等参数;model、messages、stream 由程序控制,不会被覆盖。") } .font(.caption) } 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 8b79112..d4a4703 100644 --- a/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift +++ b/Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift @@ -8,7 +8,8 @@ public struct OpenAICompatibleProvider: AIProvider { public var baseURL: URL public var model: String /// User-supplied JSON object merged into every request body (top-level keys - /// only; `model` and `messages` are never overridden). Invalid JSON is ignored. + /// only; `model`, `messages` and `stream` are never overridden). Invalid JSON + /// is ignored. public let extraFieldsJSON: String let credentials: CredentialStore let session: URLSession @@ -57,7 +58,7 @@ 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 key != "model" && key != "messages" { + for (key, value) in Self.extraFields(from: extraFieldsJSON) where !Self.reservedKeys.contains(key) { body[key] = value } request.httpBody = try JSONSerialization.data(withJSONObject: body) @@ -79,6 +80,10 @@ 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] { 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 3745f11..dc2fa98 100644 --- a/Packages/AIMEKit/Sources/AIMECore/UsageStats.swift +++ b/Packages/AIMEKit/Sources/AIMECore/UsageStats.swift @@ -11,8 +11,8 @@ 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` and `messages` - /// stay under program control). Empty means no extra fields. + /// 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 diff --git a/Packages/AIMEKit/Tests/AIMEAITests/AITests.swift b/Packages/AIMEKit/Tests/AIMEAITests/AITests.swift index 7d8645c..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.. Date: Sun, 11 Oct 2026 12:47:00 +0800 Subject: [PATCH 3/4] =?UTF-8?q?ui:=20=E8=87=AA=E5=AE=9A=E4=B9=89=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=BD=93=E6=94=B9=E7=94=A8=20LabeledContent=20+=20Tex?= =?UTF-8?q?tEditor=EF=BC=8C=E4=B8=8E=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E9=A1=B9=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 校验提示 Label 移到输入框下方(JSON 有效/不合法) - 输入框改为可滚动的 TextEditor(60–140pt),空时显示示例占位 - 按钮 tooltip 简化为厂商名,说明文案精简为一行 - 长按失效的 .help 替换为简短提示 --- Apps/AIMESettings/Views/AIAssistantView.swift | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/Apps/AIMESettings/Views/AIAssistantView.swift b/Apps/AIMESettings/Views/AIAssistantView.swift index a26f6cb..9156da3 100644 --- a/Apps/AIMESettings/Views/AIAssistantView.swift +++ b/Apps/AIMESettings/Views/AIAssistantView.swift @@ -137,7 +137,7 @@ struct AIAssistantView: View { Label("JSON 有效,将合并 \(object.count) 个字段", systemImage: "checkmark.circle.fill") .foregroundStyle(Theme.success) } else { - Label("JSON 格式不正确,不会被发送", systemImage: "exclamationmark.triangle.fill") + Label("不是合法的 JSON 对象,不会被发送", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(Theme.warning) } } @@ -223,28 +223,45 @@ struct AIAssistantView: View { Button("保存并验证", action: saveKeyAndVerify) .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - VStack(alignment: .leading, spacing: 6) { - Text("自定义请求体") - TextField("{\"enable_thinking\": false}", text: $extraJSON, axis: .vertical) - .font(.caption.monospaced()) - .textFieldStyle(.roundedBorder) - .lineLimit(3...7) + LabeledContent { + VStack(alignment: .leading, spacing: 6) { + 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) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("自定义请求体") + Text("顶层字段合并进每次请求的 body,可覆盖 temperature、max_tokens 等;model、messages、stream 由程序控制。") + .font(.caption).foregroundStyle(.secondary) + } + } + VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { - Text("关闭思考:").foregroundStyle(.secondary) + Text("关闭思考").foregroundStyle(.secondary) ForEach(Self.thinkingPresets, id: \.title) { preset in Button(preset.title) { mergeFields(preset.fields) } .controlSize(.small) - .help(preset.hint) + .help(preset.title) } - Spacer() - } - .font(.caption) - HStack(spacing: 6) { - extraJSONStatus - Text("顶层字段合并进每次请求的 body,可覆盖 temperature、max_tokens 等参数;model、messages、stream 由程序控制,不会被覆盖。") } - .font(.caption) + Text("\(Self.thinkingPresets.map(\.title).joined(separator: "、")) — 按各家官方文档填入对应的关闭思考参数。") + .font(.caption2).foregroundStyle(.tertiary) } + .font(.caption) } LabeledContent("状态") { if providerID == "openai" { From d003ab85ec98c4c169cf94759e141655d21eb244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=A2=E6=9D=A1?= Date: Sun, 11 Oct 2026 12:49:04 +0800 Subject: [PATCH 4/4] =?UTF-8?q?ui:=20=E8=87=AA=E5=AE=9A=E4=B9=89=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=BD=93=E6=94=B9=E5=9B=9E=E4=B8=8A=E4=B8=8B=E5=B8=83?= =?UTF-8?q?=E5=B1=80=EF=BC=8C=E4=BF=AE=E6=AD=A3=E7=A9=BA=E6=80=81=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E7=AC=A6=E4=B8=8E=E5=85=89=E6=A0=87=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Apps/AIMESettings/Views/AIAssistantView.swift | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/Apps/AIMESettings/Views/AIAssistantView.swift b/Apps/AIMESettings/Views/AIAssistantView.swift index 9156da3..7f98efa 100644 --- a/Apps/AIMESettings/Views/AIAssistantView.swift +++ b/Apps/AIMESettings/Views/AIAssistantView.swift @@ -223,31 +223,26 @@ struct AIAssistantView: View { Button("保存并验证", action: saveKeyAndVerify) .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - LabeledContent { - VStack(alignment: .leading, spacing: 6) { - 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) - } + 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) - } - } label: { - VStack(alignment: .leading, spacing: 2) { - Text("自定义请求体") - Text("顶层字段合并进每次请求的 body,可覆盖 temperature、max_tokens 等;model、messages、stream 由程序控制。") - .font(.caption).foregroundStyle(.secondary) + HStack(spacing: 6) { + extraJSONStatus } + .font(.caption) } VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) {