feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -27,7 +27,7 @@ public enum AIAddressExtraction: Sendable {
"", "没有", "没有地址", "没有地点", "无地址", "无地点",
"没有可导航的地点", "没有可导航的地址",
"no address", "no addresses", "no location", "no locations",
"no place", "no places", "no destination",
"no place", "no places", "no destination"
]
/// Lines to send to the host. Empty do not run the companion Shortcut.
@@ -49,7 +49,7 @@ public enum AIAgentShortcutRun {
var items = [
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "input", value: "text"),
URLQueryItem(name: "text", value: text),
URLQueryItem(name: "text", value: text)
]
if let xSuccess {
items.append(URLQueryItem(name: "x-success", value: xSuccess))
@@ -4,8 +4,8 @@
// Observable facade over the persisted skill layout. The Skills tab mutates
// this; the keyboard reads the same App Group snapshot on each config poll.
import Foundation
import Combine
import Foundation
@MainActor
public final class AIAgentSkillLayoutStore: ObservableObject {
@@ -65,7 +65,7 @@ public enum AIClipboardPrompt: Sendable {
}
private static let keywords = [
"剪贴板", "剪切板", "剪贴版", "粘贴板", "clipboard",
"剪贴板", "剪切板", "剪贴版", "粘贴板", "clipboard"
]
private static func trimmed(_ text: String) -> String {
@@ -43,6 +43,11 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
/// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut.
public var requiresShortcut: Bool { kind == .export && shortcutName != nil }
public var isUserCreated: Bool { id.hasPrefix("user.") }
/// The server applies the final model policy; this only preserves whether
/// the user invoked a built-in transform or a custom skill.
public var managedGatewayTaskKind: ManagedGatewayTaskKind {
isUserCreated ? .customSkill : .clipboardTransform
}
public init(
id: String,
@@ -165,7 +170,7 @@ public enum AIClipboardSkillCatalog: Sendable {
descriptionKey: "skills.navigate.description",
kind: .export,
isDefault: false
),
)
]
/// Legacy alias: the three default transform skills used to be the whole list.
@@ -19,7 +19,7 @@ public enum AIEventExtraction: Sendable {
"", "没有", "没有日程", "没有事件", "无日程",
"没有日期", "没有时间", "没有日期或时间", "没有日期和时间",
"no events", "no event", "no calendar events",
"no date", "no time", "no date or time", "no date and time",
"no date", "no time", "no date or time", "no date and time"
]
/// Lines to send to the companion Shortcut. Empty do not run it.
@@ -37,7 +37,7 @@ public struct AIHintKeywordCompressor: Sendable {
"text": $0.displayText,
"title": $0.metadata?.title ?? "",
"category": $0.category,
"source": $0.source,
"source": $0.source
]
}
let json = try JSONSerialization.data(withJSONObject: payload)
@@ -88,12 +88,12 @@ public enum AIHintKeywordExtractor: Sendable {
private static let zhPrefixes = [
"全网热点:", "全网热点:", "临近节日:", "临近节日:",
"历史上的今天:", "历史上的今天:", "今日一句:", "今日一句:",
"查百科:", "查百科:", "聊聊", "看看",
"查百科:", "查百科:", "聊聊", "看看"
]
private static let enPrefixes = [
"Trending: ", "Upcoming: ", "On this day: ",
"Chat about ", "Chat ", "Weather in ",
"Chat about ", "Chat ", "Weather in "
]
// MARK: - Chunks
@@ -88,7 +88,7 @@ public enum AIHintLocalCatalog: Sendable {
priority: 36,
source: "local",
locale: "zh"
),
)
]
private static let enCards: [AIHintCard] = [
@@ -168,6 +168,6 @@ public enum AIHintLocalCatalog: Sendable {
priority: 36,
source: "local",
locale: "en"
),
)
]
}
@@ -60,7 +60,7 @@ public enum AIMapNavigation: Sendable {
URLQueryItem(name: "sname", value: originName),
URLQueryItem(name: "dname", value: route.destination),
URLQueryItem(name: "dev", value: "0"),
URLQueryItem(name: "t", value: "0"),
URLQueryItem(name: "t", value: "0")
]
if useLegacyScheme {
return makeURL(scheme: "iosamap", host: "path", path: nil, items: items)
@@ -78,7 +78,7 @@ public enum AIMapNavigation: Sendable {
var items = [
URLQueryItem(name: "destination", value: "name:\(route.destination)"),
URLQueryItem(name: "mode", value: "driving"),
URLQueryItem(name: "src", value: baiduSource),
URLQueryItem(name: "src", value: baiduSource)
]
if let origin = route.origin {
items.insert(
@@ -92,7 +92,7 @@ public enum AIMapNavigation: Sendable {
private static func appleURL(_ route: AIMapRoute) -> URL {
var items = [
URLQueryItem(name: "daddr", value: route.destination),
URLQueryItem(name: "dirflg", value: "d"),
URLQueryItem(name: "dirflg", value: "d")
]
if let origin = route.origin {
items.insert(URLQueryItem(name: "saddr", value: origin), at: 0)
@@ -20,7 +20,7 @@ public enum AINoteExport: Sendable {
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有标题", "无标题",
"no title", "no note", "no notes",
"no title", "no note", "no notes"
]
/// One string for Shortcuts: `title||OSG_NOTE||body`. Empty do not run it.
@@ -139,7 +139,7 @@ public enum AINoteExport: Sendable {
("", ""),
("", ""),
("'", "'"),
("", ""),
("", "")
]
var text = line
for (open, close) in pairs where text.count >= 2 {
@@ -58,7 +58,7 @@ public enum AIQuestionPromptComposer {
.system(systemPrompt(
targetLocaleID: targetLocaleID,
responseLength: responseLength
)),
))
]
for turn in turns.suffix(AIQuestionLimits.retainedConversationRounds) {
messages.append(.user(turn.question))
@@ -128,8 +128,20 @@ public struct AIQuestionService: Sendable {
public static func configured(
store: any ConfigurationStore,
conversations: AIConversationStore,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
thinkingEnabled: Bool = true
) throws -> AIQuestionService {
if store.credentialSource == .managed {
return AIQuestionService(
client: ManagedLLMClient(
capability: .assistant,
taskKind: taskKind,
grants: GatewayGrantCoordinator()
),
conversations: conversations,
responseLength: store.aiResponseLength
)
}
// Same provider + baseURL + model resolution as dictation polish so the
// Settings LLM card is the single source of truth for both modes.
let providerID = PolishingService.resolvedProviderId(
@@ -15,7 +15,7 @@ public enum AITodoExtraction: Sendable {
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有待办", "没有待办事项", "无待办", "无待办事项",
"no tasks", "no task", "no todos", "no to-dos", "no to-do",
"no actionable items", "no action items",
"no actionable items", "no action items"
]
/// Titles to send to the companion Shortcut. Empty do not run it.
@@ -45,7 +45,7 @@ public struct AnthropicMessagesClient: LLMClient {
try await complete(
messages: [
.system(systemPrompt),
.user(text),
.user(text)
],
timeout: timeout,
options: options
@@ -168,13 +168,13 @@ public struct AnthropicMessagesClient: LLMClient {
// Anthropic requires max_tokens > thinking.budget_tokens.
"max_tokens": thinkingEnabled ? answerTokens + thinkingBudget : answerTokens,
"system": systemPrompt,
"messages": conversation,
"messages": conversation
]
if thinkingEnabled {
// Extended thinking; sampling knobs are ignored while thinking runs.
body["thinking"] = [
"type": "enabled",
"budget_tokens": thinkingBudget,
"budget_tokens": thinkingBudget
]
} else {
if let temperature = options.temperature {
@@ -190,8 +190,8 @@ public struct AnthropicMessagesClient: LLMClient {
[
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 3,
],
"max_uses": 3
]
]
}
if stream {
@@ -93,7 +93,7 @@ public struct AppContextDetector: Sendable {
"import ", "package ", "namespace ",
"def ", "var ", "let ", "const ",
"if (", "if (", "} else", "} catch",
"=> {", "-> {",
"=> {", "-> {"
]
let hasIndentation = tail.contains("\n ") || tail.contains("\t")
let hasCodeKeyword = codeKeywords.contains(where: { tail.contains($0) })
+11 -4
View File
@@ -56,9 +56,10 @@ public struct AppGroupStore: @unchecked Sendable {
}
private func mutateConfiguration(_ transform: (inout AppGroupConfiguration) -> Void) {
var config = AppGroupConfiguration.load(fromAvailable: defaults)
let baseline = AppGroupConfiguration.load(fromAvailable: defaults)
var config = baseline
transform(&config)
config.save(to: defaults)
config.saveChanges(since: baseline, to: defaults)
}
// MARK: - Reads
@@ -74,6 +75,7 @@ public struct AppGroupStore: @unchecked Sendable {
public var modeId: String { configuration.modeId }
public var localeId: String { configuration.localeId }
public var engineMode: String { configuration.engineMode }
public var credentialSource: CredentialSource { configuration.credentialSource }
public var uiLanguage: AppUILanguage { configuration.uiLanguage }
public var translationEnabled: Bool { configuration.translationEnabled }
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
@@ -121,6 +123,11 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setCredentialSource(_ source: CredentialSource) {
mutateConfiguration { $0.credentialSource = source }
AppGroupConfigDarwin.postConfigChanged()
}
public func setUILanguage(_ language: AppUILanguage) {
mutateConfiguration { $0.uiLanguage = language }
}
@@ -387,7 +394,7 @@ public struct AppGroupStore: @unchecked Sendable {
// MARK: - Client
public func makeClient() -> LLMClient {
configuration.makeClient()
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
configuration.makeClient(taskKind: taskKind)
}
}
@@ -194,7 +194,7 @@ public enum ClipboardHistoryPolicy: Sendable {
("xoxa-", 24, true),
("xoxr-", 24, true),
("AKIA", 20, true),
("ASIA", 20, true),
("ASIA", 20, true)
]
return credentialCandidates(in: text).contains { candidate in
guard candidate.allSatisfy(isCredentialCharacter) else { return false }
@@ -3,8 +3,8 @@
//
// App Groupbacked clipboard history (local only; not iCloud-synced).
import Foundation
import Combine
import Foundation
@MainActor
public final class ClipboardHistoryStore: ObservableObject {
@@ -84,7 +84,7 @@ public enum DemoDataSeeder {
("cloud", "Summarize yesterday's dictation stats for the weekly report."),
("local", "词库里加上 Cursor、DeepSeek、Qwen3-ASR,方便识别专有名词。"),
("local", "跨设备同步先关掉,演示数据用本地占位,避免被 iCloud 覆盖。"),
("local", "把首页近七天柱状图补齐,看起来更有真实使用痕迹。"),
("local", "把首页近七天柱状图补齐,看起来更有真实使用痕迹。")
]
var entries: [SpeechHistoryEntry] = []
@@ -119,7 +119,7 @@ public enum DemoDataSeeder {
("SpeechAnalyzer", ["语音分析器"], .technical, 7),
("Live Activity", ["灵动岛"], .custom, 5),
("StoreKit", ["内购"], .technical, 4),
("Rocky", ["rocky"], .properNoun, 3),
("Rocky", ["rocky"], .properNoun, 3)
]
var dictionary = PersonalDictionary()
@@ -181,9 +181,14 @@ public final class SettingsCloudSync {
to store: AppGroupStore,
postNotification: Bool
) {
var config = store.configurationSnapshot()
let baseline = store.configurationSnapshot()
var config = baseline
settings.applying(to: &config)
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.latestUpdatedAt)
store.saveConfiguration(
config,
since: baseline,
settingsCloudUpdatedAt: settings.latestUpdatedAt
)
saveLocalPayload(settings, to: store.defaults)
if postNotification {
AppGroupConfigDarwin.postConfigChanged()
@@ -220,9 +225,12 @@ private extension AppGroupStore {
AppGroupConfiguration.load(fromAvailable: defaults)
}
func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) {
let config = configuration
config.save(to: defaults)
func saveConfiguration(
_ configuration: AppGroupConfiguration,
since baseline: AppGroupConfiguration,
settingsCloudUpdatedAt: Date
) {
configuration.saveChanges(since: baseline, to: defaults)
defaults.set(
settingsCloudUpdatedAt.timeIntervalSince1970,
forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt
+19 -19
View File
@@ -7,8 +7,8 @@
// (`KeyboardViewController`) re-exports the same type as a typealias so
// existing call sites (`KeyboardViewController.State`) keep compiling.
import Foundation
import Combine
import Foundation
import SwiftUI
import UIKit
@@ -310,13 +310,13 @@ public final class KeyboardState: ObservableObject {
}
// Action hooks injected by the view controller at install time.
public var beginRecording: () -> Void = {}
public var endRecording: () -> Void = {}
public var tapMic: () -> Void = {}
public var beginRecording: () -> Void = {}
public var endRecording: () -> Void = {}
public var tapMic: () -> Void = {}
/// Starts/cancels a bounded host-audio prime from the user's mic touch.
public var setMicTouchActive: (Bool) -> Void = { _ in }
public var setMicTouchActive: (Bool) -> Void = { _ in }
/// Discards the complete normal-dictation round, including late ASR/LLM output.
public var cancelVoiceInput: () -> Void = {}
public var cancelVoiceInput: () -> Void = {}
public var beginEditLastInput: () -> Void = {}
public var stopEditListening: () -> Void = {}
public var confirmEditResult: () -> Void = {}
@@ -334,7 +334,7 @@ public final class KeyboardState: ObservableObject {
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
/// Writes extract-todos titles and opens the host to run the Shortcut.
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
public var openInputMethodSetup: () -> Void = {}
@@ -353,25 +353,25 @@ public final class KeyboardState: ObservableObject {
/// ownership cycle; UIKit's standard all-touch-events action provides both
/// tap-to-advance and long-press input-mode selection.
public weak var inputModeController: UIInputViewController?
public var startFlowSession: () -> Void = {}
public var setMode: (InputMode) -> Void = { _ in }
public var setLocale: (String) -> Void = { _ in }
public var setEngineMode: (String) -> Void = { _ in }
public var startFlowSession: () -> Void = {}
public var setMode: (InputMode) -> Void = { _ in }
public var setLocale: (String) -> Void = { _ in }
public var setEngineMode: (String) -> Void = { _ in }
/// Only the locale picker remains; `enabled`
/// is derived from the locale id, so there's no separate toggle to
/// persist. Wired in `KeyboardViewController.installStateActions`.
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
public var insertNewline: () -> Void = {}
public var insertSpace: () -> Void = {}
public var deleteBackward: () -> Void = {}
public var insertNewline: () -> Void = {}
public var insertSpace: () -> Void = {}
public var deleteBackward: () -> Void = {}
/// Undo the last voice insertion when `undoAvailable` is true.
public var undoLastInsertion: () -> Void = {}
public var undoLastInsertion: () -> Void = {}
/// Redo the last undone voice insertion when `redoAvailable` is true.
public var redoLastInsertion: () -> Void = {}
public var redoLastInsertion: () -> Void = {}
/// Copy the current text selection to the pasteboard.
public var copySelection: () -> Void = {}
public var copySelection: () -> Void = {}
/// Cut the current text selection (copy + delete).
public var cutSelection: () -> Void = {}
public var cutSelection: () -> Void = {}
/// Switch voice typing. No-ops when voice pipeline is active.
public var setSurface: (Surface) -> Void = { _ in }
@@ -455,4 +455,4 @@ extension KeyboardState.Phase.ErrorKind {
return .noSpeechDetected
}
}
}
}
+6 -6
View File
@@ -116,7 +116,7 @@ public enum Keychain: @unchecked Sendable {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account(for: providerId),
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -252,7 +252,7 @@ public enum Keychain: @unchecked Sendable {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: asrAccount(for: providerId),
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -393,7 +393,7 @@ public enum Keychain: @unchecked Sendable {
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecMatchLimit as String: kSecMatchLimitOne
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -513,7 +513,7 @@ public enum Keychain: @unchecked Sendable {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
kSecAttrAccount as String: legacyAccount
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -933,7 +933,7 @@ public enum Keychain: @unchecked Sendable {
kSecAttrService as String: onboardingService,
kSecAttrAccount as String: onboardingAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecMatchLimit as String: kSecMatchLimitOne
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -962,7 +962,7 @@ public enum Keychain: @unchecked Sendable {
var baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: onboardingService,
kSecAttrAccount as String: onboardingAccount,
kSecAttrAccount as String: onboardingAccount
]
#if os(macOS)
baseQuery[kSecUseDataProtectionKeychain as String] = true
+2 -2
View File
@@ -48,7 +48,7 @@ enum LLMHTTPDiagnostics {
"x-request-id",
"request-id",
"x-correlation-id",
"cf-ray",
"cf-ray"
]
.compactMap { response.value(forHTTPHeaderField: $0) }
.compactMap(safeToken)
@@ -240,7 +240,7 @@ public struct OpenAICompatibleClient: LLMClient {
try await complete(
messages: [
.system(systemPrompt),
.user(text),
.user(text)
],
timeout: timeout,
options: options
@@ -18,7 +18,7 @@ public enum LocalASRBiasAdapter {
"com.sublimetext.4",
"com.apple.Terminal",
"com.googlecode.iterm2",
"dev.warp.Warp-Stable",
"dev.warp.Warp-Stable"
]
public static func adapt(
@@ -52,7 +52,7 @@ public enum PolishOutputValidator {
#"https?://[^\s<>"']+"#,
#"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b"#,
#"\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b"#,
#"\b[A-Za-z]+[a-z0-9][A-Z][A-Za-z0-9]*\b"#,
#"\b[A-Za-z]+[a-z0-9][A-Z][A-Za-z0-9]*\b"#
]
var result = Set<String>()
for pattern in patterns {
@@ -48,6 +48,15 @@ public actor PolishingService {
let qualityDegraded: Bool
}
private struct PolishRequest {
let raw: String
let mode: PolishMode
let systemPrompt: String?
let providerIdOverride: String?
let taskKind: ManagedGatewayTaskKind?
let context: PolishContext?
}
public enum PolishError: Error, Equatable {
case noTranscript
case timeout
@@ -100,14 +109,18 @@ public actor PolishingService {
mode: PolishMode = .polish,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
context: PolishContext? = nil
) async throws -> String {
try await performPolish(
raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: context
PolishRequest(
raw: raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
context: context
)
).text
}
@@ -118,28 +131,31 @@ public actor PolishingService {
mode: PolishMode = .polish,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
context: PolishContext? = nil
) async throws -> PolishOutcome {
try await performPolish(
raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: context
PolishRequest(
raw: raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
context: context
)
)
}
private func performPolish(
_ raw: String,
mode: PolishMode,
systemPrompt: String?,
providerIdOverride: String?,
context: PolishContext?
) async throws -> PolishOutcome {
private func performPolish(_ request: PolishRequest) async throws -> PolishOutcome {
let raw = request.raw
let mode = request.mode
let systemPrompt = request.systemPrompt
let providerIdOverride = request.providerIdOverride
let taskKind = request.taskKind
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
let resolvedContext = resolveContext(override: context)
let resolvedContext = resolveContext(override: request.context)
let activeStyleID = store.activePolishStyleId
// Two-tier short-circuit: ultra-short always; 510 CJK only for
@@ -158,7 +174,7 @@ public actor PolishingService {
return PolishOutcome(text: TranscriptPostProcessor.localClean(trimmed))
}
if injectedClient == nil {
if injectedClient == nil, store.credentialSource == .byok {
let providerId = Self.resolvedProviderId(store: store, providerIdOverride: providerIdOverride)
let hasPolishKey = Self.hasPolishAPIKey(store: store, providerId: providerId)
guard hasPolishKey else {
@@ -174,6 +190,7 @@ public actor PolishingService {
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
context: resolvedContext
)
@@ -197,11 +214,21 @@ public actor PolishingService {
return override
}
static func managedGatewayTaskKind(for mode: PolishMode) -> ManagedGatewayTaskKind {
switch mode {
case .polish:
return .dictationPolish
case .translate:
return .translation
}
}
private func polishRemote(
_ trimmed: String,
mode: PolishMode,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
context: PolishContext
) async throws -> RemotePolishResult {
let effectiveProviderId = Self.resolvedProviderId(
@@ -211,6 +238,10 @@ public actor PolishingService {
let client: LLMClient
if let injectedClient {
client = injectedClient
} else if store.credentialSource == .managed {
client = store.makeClient(
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode)
)
} else {
let preset = LLMProvider.provider(id: effectiveProviderId)
let (baseURL, model) = Self.resolveLLMEndpoint(
@@ -418,7 +449,7 @@ public actor PolishingService {
}
internal static let chineseNativeProviderIds: Set<String> = [
"zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo",
"zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo"
]
internal static func shouldUseChineseGuidance(inputText: String, providerId: String) -> Bool {
@@ -152,7 +152,7 @@ public struct ResponsesAPILLMClient: LLMClient {
"tool_choice": "auto",
"max_output_tokens": options.maxTokens ?? LLMRequest.outputTokenLimit(
for: messages.map(\.content).joined(separator: "\n")
),
)
]
if let system, !system.isEmpty {
body["instructions"] = system
@@ -21,15 +21,15 @@ public enum SearchBodyAugmentation: Sendable, Equatable {
body["tools"] = [
[
"type": "web_search",
"web_search": ["enable": true],
],
"web_search": ["enable": true]
]
]
case .moonshotBuiltinWebSearch:
body["tools"] = [
[
"type": "builtin_function",
"function": ["name": "$web_search"],
],
"function": ["name": "$web_search"]
]
]
}
}
@@ -88,7 +88,7 @@ public enum TranscriptPostProcessor: Sendable {
#"^(好的?|嗯)?(收到|谢谢)(你|啦|了|啊)?$"#,
#"^(没事)?(不用|别)(了|啦)?(谢谢)?$"#,
#"^(晚安|早安|早上好|拜拜|再见)(啦|了|啊)?$"#,
#"^(晚点再说|待会联系|先这样吧|马上到了)$"#,
#"^(晚点再说|待会联系|先这样吧|马上到了)$"#
]
private static func hasCommunicativeSignal(_ text: String) -> Bool {
@@ -97,7 +97,7 @@ public enum TranscriptPostProcessor: Sendable {
#"吗|么|怎么|什么|哪|谁|为何|为什么|为啥"#,
#"能不能|可不可以|要不要|行不行"#,
#"回他|回她"#,
#"约|见面|吃饭|电影"#,
#"约|见面|吃饭|电影"#
]
return patterns.contains { text.range(of: $0, options: .regularExpression) != nil }
}
@@ -112,13 +112,13 @@ public enum TranscriptPostProcessor: Sendable {
"面膜", "防晒", "口红", "粉底", "洗发", "咖啡", "火锅", "酒店", "餐厅",
"方案", "接口", "测试", "Key", "老板", "电影", "地铁", "快递", "会议",
"周报", "加班", "机票", "医院", "课程", "健身", "外卖", "微信", "项目",
"发布", "文档", "密码", "充电器", "门卡",
"发布", "文档", "密码", "充电器", "门卡"
]
return entities.contains { text.contains($0) }
}
private static let leadingFillers = [
"怎么说呢", "就是说", "然后那个", "嗯那个", "那个", "", "",
"怎么说呢", "就是说", "然后那个", "嗯那个", "那个", "", ""
]
private static func stripLeadingFillers(_ text: String) -> String {
@@ -249,7 +249,7 @@ public enum TranscriptPostProcessor: Sendable {
#"首先|其次|再次|最后|另外|再者|一方面|另一方面"#,
#"\b(first|second|third|fourth|fifth|finally|next|another)\b"#,
#"\b(step\s*(one|two|three|four|five|\d+))\b"#,
#"point\s*(one|two|three|four|five|\d+)"#,
#"point\s*(one|two|three|four|five|\d+)"#
]
for pattern in patterns {
if text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil {
@@ -356,7 +356,7 @@ public enum TranscriptPostProcessor: Sendable {
// Collapse duplicate Chinese / Western punctuation.
let dupPairs = [
("。。", ""), (",,", ""), ("??", ""), ("!!", ""),
("..", "."), (",,", ","), ("??", "?"), ("!!", "!"),
("..", "."), (",,", ","), ("??", "?"), ("!!", "!")
]
for (dup, single) in dupPairs {
while result.contains(dup) {
@@ -418,7 +418,7 @@ public enum TranscriptPostProcessor: Sendable {
public static func stripExplanatoryPrefix(from text: String) -> String {
let prefixes = [
"以下是", "处理后", "处理后的文本", "输出如下", "结果如下",
"Here is", "Here's", "Output:", "Result:", "Processed text:",
"Here is", "Here's", "Output:", "Result:", "Processed text:"
]
var result = text
for prefix in prefixes {
@@ -449,7 +449,7 @@ public enum TranscriptPostProcessor: Sendable {
private static func endsWithSentenceTerminator(_ text: String) -> Bool {
guard let last = text.unicodeScalars.last else { return false }
let terminators: Set<Unicode.Scalar> = [
"", "", "", "", "!", "?", ".", ";", "", "", ":",
"", "", "", "", "!", "?", ".", ";", "", "", ":"
]
return terminators.contains(last)
}
@@ -35,6 +35,16 @@ public enum TranscriptionPolishFallback: Sendable {
if error is LLMError {
return degradedWarning()
}
if let managedError = error as? ManagedGatewayError {
switch managedError {
case .insufficientCredits:
return SharedL10n.string("flow.warning.managedInsufficientCredits")
case .missingGrant, .scopeNotGranted, .invalidGrant:
return SharedL10n.string("flow.warning.managedGrantRejected")
case .timeout, .server:
return degradedWarning()
}
}
return nil
}