chore(release): bump version to 2.1.6 (build 131)
Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs, docs/llms.txt). fix(flow): host-app identification now compiles in every build configuration (Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and could not auto-return after a startflow handoff. Debug-only logging and the Home test card are unchanged. Also includes the pending work in the tree: - Home: single account/setup card surface (HomeAccountSurfacePolicy/Views) - Wide screens: voice key row and centred mic placement, iPhone Duo layout - Keyboard: clipboard skill ranking and AI skill layout updates - Debug: account session handoff to the Simulator (+ import script) - Docs: portfolio assets, privacy policy URL, site/README refresh
This commit is contained in:
@@ -316,6 +316,14 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
/// Legacy ID consolidated into `summarizeID`.
|
||||
public static let extractConclusionsID = "extractConclusions"
|
||||
public static let translateID = "translate"
|
||||
/// Translates text that is already in the device language into the user's
|
||||
/// configured translation target language. The mirror of `translateID`
|
||||
/// (which handles foreign text → device language); the keyboard offers one
|
||||
/// or the other depending on the copy's language, never both.
|
||||
public static let translateToTargetID = "translateToTarget"
|
||||
/// Fixes grammar, improves wording, and polishes foreign-language text while
|
||||
/// keeping it in its original language. Offered only for foreign copies.
|
||||
public static let polishOriginalID = "polishOriginal"
|
||||
public static let acceptInvitationID = "acceptInvitation"
|
||||
public static let declineInvitationID = "declineInvitation"
|
||||
public static let acceptTaskID = "acceptTask"
|
||||
@@ -416,6 +424,24 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: translateToTargetID,
|
||||
systemImage: "globe",
|
||||
titleKey: "keyboard.ai.skill.translateToTarget",
|
||||
cardTitleKey: "skills.translateToTarget.name",
|
||||
descriptionKey: "skills.translateToTarget.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: polishOriginalID,
|
||||
systemImage: "wand.and.stars",
|
||||
titleKey: "keyboard.ai.skill.polishOriginal",
|
||||
cardTitleKey: "skills.polishOriginal.name",
|
||||
descriptionKey: "skills.polishOriginal.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: openLinkID,
|
||||
systemImage: "arrow.up.right.square.fill",
|
||||
@@ -692,6 +718,14 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
now: now
|
||||
)
|
||||
}
|
||||
// Polishing rewrites the user's own draft, so it carries the same
|
||||
// never-answer boundary as "Speak as me" (built-in prompt only).
|
||||
if skill.id == polishOriginalID, skill.customPrompt == nil {
|
||||
let boundary = locale == "zh"
|
||||
? PolishPromptComposer.chineseNeverAnswerContract
|
||||
: PolishPromptComposer.englishNeverAnswerContract
|
||||
return "\(baseInstruction)\n\(boundary)"
|
||||
}
|
||||
if canonicalID(for: skill.id) == speakAsMeID {
|
||||
return speakAsMeInstruction(
|
||||
baseInstruction,
|
||||
@@ -724,10 +758,27 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
: "To \(target)"
|
||||
}
|
||||
|
||||
/// Chip label for "Translate to <target>". Nil when the user has translation
|
||||
/// turned off (or the id is unknown), in which case the skill is not offered.
|
||||
/// The language name is localized to the UI language ("英语" / "English").
|
||||
public static func translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: String,
|
||||
uiLanguage: AppUILanguage
|
||||
) -> String? {
|
||||
guard !TranslationLanguageCatalog.isOff(translationTargetLocaleId) else { return nil }
|
||||
let language = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
|
||||
let displayLocale = Locale(identifier: uiLanguage.resolvedLanguageCode())
|
||||
let name = displayLocale.localizedString(forIdentifier: language.id)
|
||||
?? language.nativeName
|
||||
return uiLanguage.resolvedLanguageCode() == "zh-Hans"
|
||||
? "翻译\(name)"
|
||||
: "To \(name)"
|
||||
}
|
||||
|
||||
public static func instruction(
|
||||
skillID: String,
|
||||
locale: String,
|
||||
translationTargetLocaleId _: String,
|
||||
translationTargetLocaleId: String,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages,
|
||||
now: Date = Date()
|
||||
) -> String {
|
||||
@@ -779,6 +830,15 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
locale: locale,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
case translateToTargetID:
|
||||
return translateToTargetInstruction(
|
||||
zh: zh,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
case polishOriginalID:
|
||||
return zh
|
||||
? "请在不改变语言的前提下优化剪贴板文本:先纠正语法、拼写和标点错误,再改善不自然或不准确的措辞,最后适度润色,让表达通顺、自然、得体。严格使用原文的语言输出,不要翻译;保留原意、事实、数字、人名、链接、代码和段落格式,不要扩写或删减要点。只输出优化后的文本,不要解释,也不要列出修改点。"
|
||||
: "Improve the clipboard text without changing its language: first fix grammar, spelling, and punctuation errors, then improve awkward or inaccurate wording, and finally polish lightly so it reads smoothly, naturally, and appropriately. Output strictly in the source language and never translate. Preserve the meaning, facts, numbers, names, links, code, and paragraph formatting; do not add or drop points. Output only the improved text, with no explanation and no list of changes."
|
||||
case businessReplyID:
|
||||
return zh
|
||||
? "请写一段专业但不官腔的商务聊天回复,表达直接、自然,保留人名、组织名、时间和承诺边界,可直接发送。不要套用正式邮件开场和结尾。"
|
||||
@@ -987,6 +1047,23 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
return "\(baseInstruction)\n\(conversationalBaseline)\(sceneInstruction)\n\(personalStyle)"
|
||||
}
|
||||
|
||||
/// Translate into the user's configured target language. An "off" or stale
|
||||
/// target falls back to the catalog default so the Skills-tab prompt preview
|
||||
/// never shows an empty language name.
|
||||
private static func translateToTargetInstruction(
|
||||
zh: Bool,
|
||||
translationTargetLocaleId: String
|
||||
) -> String {
|
||||
let resolved = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
|
||||
let language = TranslationLanguageCatalog.isOff(resolved.id)
|
||||
? TranslationLanguageCatalog.resolve(TranslationLanguageCatalog.defaultLocaleId)
|
||||
: resolved
|
||||
let target = language.promptLanguageName
|
||||
return zh
|
||||
? "请把剪贴板文本翻译成 \(target),准确保留原意、语气、名称、链接和格式;如果文本本身已经是 \(target),则原样输出。只输出译文,不要解释。"
|
||||
: "Translate the clipboard text into \(target), preserving meaning, tone, names, links, and formatting. If the text is already in \(target), return it unchanged. Output only the translation with no explanation."
|
||||
}
|
||||
|
||||
/// Clipboard translation always follows the device's primary system language.
|
||||
private static func translateInstruction(
|
||||
locale: String,
|
||||
|
||||
@@ -498,6 +498,12 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
if storedMigrationVersion < 8 {
|
||||
additionIDs.insert(AIClipboardSkillCatalog.blessingReplyID)
|
||||
}
|
||||
if storedMigrationVersion < 12 {
|
||||
// Translate-to-target and Polish ship as defaults; install them
|
||||
// once for existing layouts (a later opt-out stays opted out).
|
||||
additionIDs.insert(AIClipboardSkillCatalog.translateToTargetID)
|
||||
additionIDs.insert(AIClipboardSkillCatalog.polishOriginalID)
|
||||
}
|
||||
// v9 consolidates playful and business reply into Reply. Do not
|
||||
// add Reply here: `sanitized` preserves it only when any reply ID
|
||||
// was enabled, so a user's explicit disabled state stays disabled.
|
||||
@@ -529,7 +535,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 11
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 12
|
||||
|
||||
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
|
||||
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
|
||||
|
||||
@@ -40,7 +40,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
sourceText: String,
|
||||
analysis: ClipboardSemanticAnalysis,
|
||||
uiLanguage _: AppUILanguage,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
preferredLanguages: [String] = Locale.preferredLanguages,
|
||||
translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
) -> [AIClipboardSkill] {
|
||||
guard skills.count > 1 else { return skills }
|
||||
return sorted(
|
||||
@@ -48,7 +49,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
scores: relevanceScores(
|
||||
sourceText: sourceText,
|
||||
analysis: analysis,
|
||||
preferredLanguages: preferredLanguages
|
||||
preferredLanguages: preferredLanguages,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -61,13 +63,15 @@ public enum ClipboardSkillSemanticRanker {
|
||||
analysis: ClipboardSemanticAnalysis,
|
||||
uiLanguage _: AppUILanguage,
|
||||
limit: Int,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
preferredLanguages: [String] = Locale.preferredLanguages,
|
||||
translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
) -> [AIClipboardSkill] {
|
||||
guard limit > 0 else { return [] }
|
||||
var scores = relevanceScores(
|
||||
sourceText: sourceText,
|
||||
analysis: analysis,
|
||||
preferredLanguages: preferredLanguages
|
||||
preferredLanguages: preferredLanguages,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
let genericReply = suppressesInterpersonalRouting(analysis, sourceText: sourceText)
|
||||
? nil
|
||||
@@ -114,7 +118,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
private static func relevanceScores(
|
||||
sourceText: String,
|
||||
analysis: ClipboardSemanticAnalysis,
|
||||
preferredLanguages: [String]
|
||||
preferredLanguages: [String],
|
||||
translationTargetLocaleId: String
|
||||
) -> [String: Int] {
|
||||
var scores: [String: Int] = [:]
|
||||
func boost(_ id: String, _ value: Int) {
|
||||
@@ -149,7 +154,20 @@ public enum ClipboardSkillSemanticRanker {
|
||||
analysis.language,
|
||||
preferredLanguages: preferredLanguages
|
||||
) {
|
||||
// Foreign text: translate it into the device language (auto-translate
|
||||
// uses the same path) and offer to polish it in its own language.
|
||||
// Polish is a secondary option, so it ranks below a real Reply.
|
||||
boost(AIClipboardSkillCatalog.translateID, 230)
|
||||
boost(AIClipboardSkillCatalog.polishOriginalID, 100)
|
||||
} else if isDeviceLanguage(
|
||||
analysis.language,
|
||||
preferredLanguages: preferredLanguages
|
||||
), canTranslateToTarget(
|
||||
from: analysis.language,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
) {
|
||||
// Device-language text: offer the user's configured target language.
|
||||
boost(AIClipboardSkillCatalog.translateToTargetID, 225)
|
||||
}
|
||||
|
||||
if analysis.hasAddress {
|
||||
@@ -390,6 +408,39 @@ public enum ClipboardSkillSemanticRanker {
|
||||
)
|
||||
}
|
||||
|
||||
/// Confidently the device's primary language — the counterpart of
|
||||
/// `isLanguageMismatch`. An unknown / low-confidence label is neither.
|
||||
private static func isDeviceLanguage(
|
||||
_ language: ClipboardLanguageLabel?,
|
||||
preferredLanguages: [String]
|
||||
) -> Bool {
|
||||
guard let language, language.confidence >= languageConfidenceThreshold else {
|
||||
return false
|
||||
}
|
||||
return SystemLanguageResolver.isSameLanguage(
|
||||
sourceIdentifier: language.identifier,
|
||||
targetIdentifier: SystemLanguageResolver.primaryIdentifier(
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// "Translate to <target>" needs a real target that differs from the
|
||||
/// copy's own language; translating into the same language is a no-op.
|
||||
private static func canTranslateToTarget(
|
||||
from language: ClipboardLanguageLabel?,
|
||||
translationTargetLocaleId: String
|
||||
) -> Bool {
|
||||
guard let language,
|
||||
!TranslationLanguageCatalog.isOff(translationTargetLocaleId) else {
|
||||
return false
|
||||
}
|
||||
return !SystemLanguageResolver.isSameLanguage(
|
||||
sourceIdentifier: language.identifier,
|
||||
targetIdentifier: translationTargetLocaleId
|
||||
)
|
||||
}
|
||||
|
||||
private static func isRoutingEvidence(_ label: ClipboardIntentLabel) -> Bool {
|
||||
label.isDetected && label.isApprovedForAutomaticRouting
|
||||
}
|
||||
|
||||
@@ -237,13 +237,30 @@ public final class KeyboardState {
|
||||
/// The view controller resolves this from device idiom + horizontal size
|
||||
/// class so SwiftUI and the UIKit height constraint cannot disagree.
|
||||
public var usesIPadLayoutMetrics: Bool = false
|
||||
/// The custom system-keyboard switch is iPad-only. iPhone relies on the
|
||||
/// system-provided switch below the keyboard instead of showing a duplicate.
|
||||
/// Unfolded iPhone Duo: phone key layout on rows sized to the system
|
||||
/// keyboard of the inner display. Resolved alongside
|
||||
/// `usesIPadLayoutMetrics`; never true at the same time.
|
||||
public var usesExpandedPhoneLayoutMetrics: Bool = false
|
||||
/// Any iPhone in landscape (vertically compact): a shorter phone panel.
|
||||
/// Never true together with the iPad or expanded-phone metrics.
|
||||
public var usesCompactHeightPhoneLayoutMetrics: Bool = false
|
||||
/// Whether the keyboard draws its own globe key: always on iPad, and
|
||||
/// elsewhere only when UIKit reports no system switch below the keyboard.
|
||||
public var showsSystemGlobeKey: Bool = false
|
||||
/// Width the controller sized the keyboard to. Both the UIKit height
|
||||
/// constraint and the SwiftUI key grid pick their metrics from this one
|
||||
/// value so they can never disagree and clip the bottom row.
|
||||
public var layoutWidth: CGFloat = 0
|
||||
/// Wide hosts swap the voice surface's circle cluster for a keyboard-style
|
||||
/// bottom row aligned with the typing keys (see `VoiceKeyRowLayout`).
|
||||
public var usesVoiceKeyRow: Bool {
|
||||
VoiceKeyRowLayout.isAvailable(
|
||||
usesIPadMetrics: usesIPadLayoutMetrics,
|
||||
usesExpandedPhoneMetrics: usesExpandedPhoneLayoutMetrics,
|
||||
usesCompactHeightPhoneMetrics: usesCompactHeightPhoneLayoutMetrics,
|
||||
hostWidth: layoutWidth
|
||||
)
|
||||
}
|
||||
/// `true` when the last voice insertion is still at the caret and can
|
||||
/// be undone (suffix-checked against `documentContextBeforeInput`).
|
||||
public var undoAvailable: Bool = false
|
||||
|
||||
Reference in New Issue
Block a user