feat(ai): unify reply center and refresh keyboard AI features

- Merge invitation, task, blessing, clarification, and empathy actions
  into a single Reply flow, with three fixed, clearly labeled stance
  choices whenever user intent must not be guessed.
- Refine clipboard semantic routing with bilingual schedule,
  confirmation, and follow-up models, conservative language thresholds,
  and explicit-assignment guard for complaint-only text.
- Persist Apple account refresh state, harden session recovery, and
  surface durable account diagnostics across keyboard and app.
- Derive personal-style prompts through two-stage corpus evidence and
  apply real low-confidence ASR tendencies instead of neutral templates.
- Localize the new reply center, clipboard semantics, and personal-style
  surfaces in both English and Simplified Chinese.
This commit is contained in:
Rocky
2026-08-29 11:51:21 +08:00
parent f91a8f2456
commit 3c10d73d7f
30 changed files with 2090 additions and 447 deletions
+2 -2
View File
@@ -609,8 +609,8 @@ public final class KeyboardViewController: UIInputViewController {
state.submitAIHint = { [weak self] card in
self?.aiKeyboardCoordinator.submitHintCard(card)
}
state.submitAIClipboardSkill = { [weak self] skill in
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
state.submitAIClipboardSkill = { [weak self] skill, replyScene in
self?.aiKeyboardCoordinator.submitClipboardSkill(skill, replyScene: replyScene)
}
state.runClipboardExportSkill = { [weak self] skillID, titles in
AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
@@ -24,6 +24,7 @@ final class AIKeyboardCoordinator {
private var hasConversationInsertionTarget = false
private var requestOOBEFeature: ManagedGatewayOOBEFeature?
private var requestExpectsReplyVariants = false
private var requestReplyVariantSet: AIReplyVariantSet = .generic
private var requestReplySourceText: String?
private var requestReplyFeedbackSource: String?
private var pendingReplyFeedbackRecordID: UUID?
@@ -58,6 +59,7 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil
requestOOBEFeature = nil
requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil
requestReplyFeedbackSource = nil
pendingStructuredReplyResult = false
@@ -80,6 +82,7 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil
requestOOBEFeature = nil
requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil
requestReplyFeedbackSource = nil
pendingStructuredReplyResult = false
@@ -114,7 +117,10 @@ final class AIKeyboardCoordinator {
}
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
func submitClipboardSkill(_ skill: AIClipboardSkill) {
func submitClipboardSkill(
_ skill: AIClipboardSkill,
replyScene: AIClipboardReplyScene? = nil
) {
guard canAcceptIdleSubmit else { return }
guard !skill.requiresShortcut
|| state.confirmedClipboardShortcutIDs.contains(skill.id) else {
@@ -175,13 +181,19 @@ final class AIKeyboardCoordinator {
for: skill,
locale: AIHintLocaleResolver.packLocale(),
translationTargetLocaleId: state.translationTargetLocaleId,
replyStyle: state.clipboardReplyStyle
replyStyle: state.clipboardReplyStyle,
replyScene: replyScene
)
let expectsReplyVariants = state.multipleReplyVariantsEnabled
&& skill.id == AIClipboardSkillCatalog.replyID
let replyVariantSet = AIReplyVariantSet.resolve(scene: replyScene)
let expectsReplyVariants = skill.id == AIClipboardSkillCatalog.replyID
&& AIReplyVariantSet.shouldGenerate(
multipleRepliesEnabled: state.multipleReplyVariantsEnabled,
scene: replyScene
)
requestReplyVariantSet = expectsReplyVariants ? replyVariantSet : .generic
requestReplySourceText = expectsReplyVariants ? material : nil
if expectsReplyVariants {
instruction += "\n\(replyVariantsOutputContract())"
instruction += "\n\(replyVariantsOutputContract(for: replyVariantSet))"
}
if skill.kind == .export {
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
@@ -295,6 +307,7 @@ final class AIKeyboardCoordinator {
if case .rejected(let rejection) = disposition {
clearPendingExportSkill()
requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil
requestReplyFeedbackSource = nil
state.aiSession.fail(message(for: rejection), utteranceID: nil)
@@ -307,6 +320,7 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil
requestOOBEFeature = nil
requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil
flow.cancelAIRecording()
state.aiSession.cancelCurrentWork()
@@ -415,6 +429,7 @@ final class AIKeyboardCoordinator {
let answer = result.text,
!answer.isEmpty else {
requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil
requestReplyFeedbackSource = nil
state.aiSession.fail(
@@ -427,10 +442,13 @@ final class AIKeyboardCoordinator {
requestExpectsReplyVariants = false
requestInsertionFingerprint = nil
let sourceText = requestReplySourceText
let variantSet = requestReplyVariantSet
requestReplyVariantSet = .generic
requestReplySourceText = nil
switch AIReplyVariantParser.parseOrFallback(
answer,
sourceText: sourceText
sourceText: sourceText,
variantSet: variantSet
) {
case .variants(let variants):
state.aiSession.receiveReplyVariants(
@@ -487,6 +505,7 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil
requestOOBEFeature = nil
requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil
requestReplyFeedbackSource = nil
state.aiSession.fail(message, utteranceID: utteranceID)
@@ -512,6 +531,7 @@ final class AIKeyboardCoordinator {
private func prepareConversationForRequest() {
requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil
requestReplyFeedbackSource = nil
pendingStructuredReplyResult = false
@@ -579,14 +599,13 @@ final class AIKeyboardCoordinator {
private func feedbackKind(
for kind: AIReplyVariant.Kind
) -> ClipboardReplyCandidateSnapshot.Kind {
switch kind {
case .ordinary:
guard let snapshotKind = ClipboardReplyCandidateSnapshot.Kind(
rawValue: kind.rawValue
) else {
assertionFailure("Unmapped reply variant kind: \(kind.rawValue)")
return .ordinary
case .formal:
return .formal
case .playful:
return .playful
}
return snapshotKind
}
/// The host conversation contains the structured JSON result rather than
@@ -602,17 +621,55 @@ final class AIKeyboardCoordinator {
state.aiSession.resetConversationPreservingAnswer()
}
private func replyVariantsOutputContract() -> String {
"""
private func replyVariantsOutputContract(
for variantSet: AIReplyVariantSet
) -> String {
let items = variantSet.kinds.map {
#"{"kind":"\#($0.rawValue)","emotion":"neutral","text":"..."}"#
}.joined(separator: ",")
let roleGuidance: String
switch variantSet {
case .generic:
roleGuidance = """
All three must keep the same semantic stance, facts, and level of commitment.
ordinary: natural for the situation; add emoji only when context makes it useful.
formal: professional and natural; add no new emoji by default.
playful: relaxed and fun. Emoji has no fixed numeric cap, may be varied when context supports it, must not become meaningless stacking, and must not default to using only 😂. This playful emoji rule overrides any personal no-emoji preference.
"""
case .invitation:
roleGuidance = """
invitationAccept: naturally accept without inventing availability or commitments.
invitationDecline: politely decline without inventing a reason.
invitationTentative: stay undecided and say only that confirmation is needed.
"""
case .task:
roleGuidance = """
taskAcknowledge: acknowledge only source-supported work and timing.
taskClarify: ask only the most important missing detail.
taskNegotiate: negotiate scope or timing without inventing constraints.
"""
case .blessing:
roleGuidance = """
blessingReturn: sincerely thank and return an appropriate wish.
blessingWarm: give a concise, warm response.
blessingPlayful: respond lightly and playfully when the context is safe.
"""
case .clarification:
roleGuidance = """
clarificationDirect: answer only the part supported by available context.
clarificationQuestion: ask one essential missing question.
clarificationConfirm: briefly confirm understanding, then ask the key question.
"""
}
return """
MULTI-REPLY OUTPUT CONTRACT (highest priority):
Return only one valid JSON object with exactly this shape and no Markdown fence or extra keys:
{"variants":[{"kind":"ordinary","emotion":"neutral","text":"..."},{"kind":"formal","emotion":"neutral","text":"..."},{"kind":"playful","emotion":"playful","text":"..."}]}
Include exactly one ordinary, one formal, and one playful item in that order. Every text must be a complete reply in the source language. All three must keep the same semantic stance, facts, and level of commitment. If the source does not establish whether the user should accept, decline, promise, schedule, or otherwise decide, do not invent that decision; stay neutral or ask for the missing detail.
{"variants":[\(items)]}
Include exactly these three kinds in the shown order. Every text must be a complete reply in the source language.
\(roleGuidance)
Every item must advance the conversation with a reaction, answer, question, decision, or next step. Never restate, paraphrase, summarize, or synonymically rewrite the clipboard text. In particular, do not begin a reply by repeating the source's subject and event. For a declarative update, react to its implication or emotion instead of reporting the update back to its sender.
Apply any <reply_scene> constraint to every item. It overrides the kind-specific tone guidance below when they conflict.
Apply the existing <user_reply_style> wording, rhythm, and stable habits to every item without changing these rules.
ordinary: natural for the situation; add emoji only when context makes it useful.
formal: professional and natural; add no new emoji by default.
playful: relaxed and fun. Emoji has no fixed numeric cap, may be varied when context supports it, must not become meaningless stacking, and must not default to using only 😂. This playful emoji rule overrides any personal no-emoji preference.
emotion must be exactly one of: neutral, warm, celebratory, empathetic, encouraging, grateful, apologetic, reassuring, playful, enthusiastic, calm. The app, not the model, chooses all icons.
"""
}
+106 -7
View File
@@ -37,9 +37,23 @@ struct AIKeyboardView: View {
static let maximumSemanticSkills = 5
}
private struct SemanticBadgeContent {
let intentKey: String?
let domainKey: String?
var text: String {
[intentKey, domainKey]
.compactMap { $0 }
.map { ExtL10n.string($0) }
.joined(separator: " · ")
}
}
#if DEBUG
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
static var debugPreviewSkills: [AIClipboardSkill]?
/// Deterministic intent/domain labels for the assistant UI harness.
static var debugPreviewSemanticBadgeKeys: (intent: String?, domain: String?)?
/// Keeps the deterministic UI harness on the tappable idle hint.
static var debugSkipsLongPressCoach = false
/// Prevents deterministic feedback previews from expiring mid-assertion.
@@ -237,9 +251,9 @@ struct AIKeyboardView: View {
} label: {
HStack(alignment: .top, spacing: Spacing.sm) {
Image(
systemName: variant.emotion.systemImage(
fallback: variant.kind
)
systemName: variant.kind.usesEmotionIcon
? variant.emotion.systemImage(fallback: variant.kind)
: variant.kind.systemImage
)
.resizable()
.scaledToFit()
@@ -315,7 +329,7 @@ struct AIKeyboardView: View {
onDismiss: dismissClipboardPresentation
)
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else if showsClipboardSkills {
} else if showsClipboardSkills || semanticBadgeContent != nil {
cancelTopBar(
action: dismissClipboardPresentation,
labelKey: "keyboard.assistant.dismissClipboard",
@@ -396,7 +410,14 @@ struct AIKeyboardView: View {
}
.accessibilityIdentifier("assistant.skillTip")
} else if showsClipboardSkills {
clipboardSkillPager
VStack(spacing: Spacing.xs) {
if let content = semanticBadgeContent {
semanticBadge(content)
}
clipboardSkillPager
}
} else if let content = semanticBadgeContent {
semanticBadge(content)
} else if let status = activeStatus {
statusText(status.text, color: status.color)
} else if showsLongPressCoach {
@@ -415,6 +436,73 @@ struct AIKeyboardView: View {
.padding(.horizontal, Spacing.md)
}
private func semanticBadge(_ content: SemanticBadgeContent) -> some View {
HStack(spacing: 5) {
Image(systemName: "tag.fill")
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
Text(content.text)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
}
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, 10)
.frame(height: 24)
.background(palette.accentMuted, in: Capsule())
.accessibilityElement(children: .combine)
.accessibilityIdentifier("assistant.semantic.badge")
.accessibilityLabel(Text(content.text))
}
private var semanticBadgeContent: SemanticBadgeContent? {
#if DEBUG
if let keys = Self.debugPreviewSemanticBadgeKeys {
return SemanticBadgeContent(
intentKey: keys.intent,
domainKey: keys.domain
)
}
#endif
guard assistantIsResting,
state.clipboardHistoryEnabled,
let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id,
AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: newest
) else {
return nil
}
let intentKey = semanticIntentKey(snapshot.analysis)
let domainKey: String? = snapshot.analysis.domain.flatMap { domain in
guard let confidence = snapshot.analysis.domainConfidence,
confidence > 0 else {
return nil
}
return domain.localizationKey
}
guard intentKey != nil || domainKey != nil else { return nil }
return SemanticBadgeContent(intentKey: intentKey, domainKey: domainKey)
}
private func semanticIntentKey(
_ analysis: ClipboardSemanticAnalysis
) -> String? {
let candidates = [
(analysis.assistantCommand, "keyboard.semantic.intent.assistantCommand"),
(analysis.informationQuery, "keyboard.semantic.intent.informationQuery"),
(analysis.systemNotification, "keyboard.semantic.intent.systemNotification")
].filter { label, _ in
label.isDetected
&& label.confidence > 0
&& label.confidence >= label.threshold
}
return candidates.max {
$0.0.confidence < $1.0.confidence
}?.1
}
private func statusText(_ text: String, color: Color) -> some View {
Text(text)
.font(TypeStyle.body)
@@ -588,7 +676,7 @@ struct AIKeyboardView: View {
private func skillChip(_ skill: AIClipboardSkill) -> some View {
Button {
state.submitAIClipboardSkill(skill)
state.submitAIClipboardSkill(skill, replyScene(for: skill))
} label: {
VStack(spacing: 6) {
Image(systemName: skill.systemImage)
@@ -610,6 +698,17 @@ struct AIKeyboardView: View {
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
private func replyScene(for skill: AIClipboardSkill) -> AIClipboardReplyScene? {
guard skill.id == AIClipboardSkillCatalog.replyID,
state.oobePracticeSession == nil,
let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return nil
}
return AIClipboardReplyScene.resolve(from: snapshot.analysis)
}
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID {
return AIClipboardSkillCatalog.translateButtonTitle(
@@ -1193,7 +1292,7 @@ struct AIKeyboardView: View {
private func resetCarousel() {
reloadHintPool(resetBag: true)
guard !showsClipboardSkills else { return }
guard !showsClipboardSkills, semanticBadgeContent == nil else { return }
showNextHint(animated: false)
}
+27
View File
@@ -314,9 +314,36 @@
"keyboard.ai.replyVariant.ordinary" = "Ordinary";
"keyboard.ai.replyVariant.formal" = "Formal";
"keyboard.ai.replyVariant.playful" = "Relaxed & playful";
"keyboard.ai.replyVariant.invitationAccept" = "Accept";
"keyboard.ai.replyVariant.invitationDecline" = "Decline";
"keyboard.ai.replyVariant.invitationTentative" = "Decide later";
"keyboard.ai.replyVariant.taskAcknowledge" = "Acknowledge";
"keyboard.ai.replyVariant.taskClarify" = "Clarify";
"keyboard.ai.replyVariant.taskNegotiate" = "Negotiate";
"keyboard.ai.replyVariant.blessingReturn" = "Thank & return wish";
"keyboard.ai.replyVariant.blessingWarm" = "Warm";
"keyboard.ai.replyVariant.blessingPlayful" = "Lighthearted";
"keyboard.ai.replyVariant.clarificationDirect" = "Direct reply";
"keyboard.ai.replyVariant.clarificationQuestion" = "Ask key detail";
"keyboard.ai.replyVariant.clarificationConfirm" = "Confirm & ask";
"keyboard.ai.replyVariant.insertHint" = "Insert this complete reply.";
"keyboard.assistant.dismissClipboard" = "Dismiss clipboard suggestions";
"keyboard.assistant.dismissClipboardHint" = "Hide the current clipboard summary and skills.";
"keyboard.semantic.intent.assistantCommand" = "Assistant command";
"keyboard.semantic.intent.informationQuery" = "Information query";
"keyboard.semantic.intent.systemNotification" = "System notification";
"keyboard.semantic.domain.finance" = "Finance";
"keyboard.semantic.domain.travel" = "Travel";
"keyboard.semantic.domain.calendar" = "Calendar";
"keyboard.semantic.domain.communication" = "Communication";
"keyboard.semantic.domain.media" = "Media";
"keyboard.semantic.domain.smartHome" = "Smart home";
"keyboard.semantic.domain.shopping" = "Shopping";
"keyboard.semantic.domain.dining" = "Dining";
"keyboard.semantic.domain.health" = "Health";
"keyboard.semantic.domain.weather" = "Weather";
"keyboard.semantic.domain.accountService" = "Account & service";
"keyboard.semantic.domain.generalKnowledge" = "General knowledge";
"keyboard.ai.error.missingAPIKey" = "Configure an AI service in the main app first";
"keyboard.ai.error.pipelineBusy" = "Voice input is busy. Try again shortly";
"keyboard.ai.error.startTimeout" = "Microphone startup timed out. Try again";
@@ -314,9 +314,36 @@
"keyboard.ai.replyVariant.ordinary" = "普通";
"keyboard.ai.replyVariant.formal" = "正式";
"keyboard.ai.replyVariant.playful" = "轻松趣味";
"keyboard.ai.replyVariant.invitationAccept" = "接受";
"keyboard.ai.replyVariant.invitationDecline" = "婉拒";
"keyboard.ai.replyVariant.invitationTentative" = "待定";
"keyboard.ai.replyVariant.taskAcknowledge" = "确认处理";
"keyboard.ai.replyVariant.taskClarify" = "澄清";
"keyboard.ai.replyVariant.taskNegotiate" = "协商";
"keyboard.ai.replyVariant.blessingReturn" = "感谢并回祝";
"keyboard.ai.replyVariant.blessingWarm" = "简短温暖";
"keyboard.ai.replyVariant.blessingPlayful" = "轻松活泼";
"keyboard.ai.replyVariant.clarificationDirect" = "直接回应";
"keyboard.ai.replyVariant.clarificationQuestion" = "追问关键点";
"keyboard.ai.replyVariant.clarificationConfirm" = "确认并追问";
"keyboard.ai.replyVariant.insertHint" = "插入这条完整回复。";
"keyboard.assistant.dismissClipboard" = "关闭剪贴板建议";
"keyboard.assistant.dismissClipboardHint" = "隐藏当前剪贴板摘要和技能。";
"keyboard.semantic.intent.assistantCommand" = "助手操作";
"keyboard.semantic.intent.informationQuery" = "信息查询";
"keyboard.semantic.intent.systemNotification" = "系统通知";
"keyboard.semantic.domain.finance" = "金融";
"keyboard.semantic.domain.travel" = "出行";
"keyboard.semantic.domain.calendar" = "日历";
"keyboard.semantic.domain.communication" = "沟通";
"keyboard.semantic.domain.media" = "媒体";
"keyboard.semantic.domain.smartHome" = "智能家居";
"keyboard.semantic.domain.shopping" = "购物";
"keyboard.semantic.domain.dining" = "餐饮";
"keyboard.semantic.domain.health" = "健康";
"keyboard.semantic.domain.weather" = "天气";
"keyboard.semantic.domain.accountService" = "账户与服务";
"keyboard.semantic.domain.generalKnowledge" = "通用知识";
"keyboard.ai.error.missingAPIKey" = "请先在主 App 配置可用的 AI 服务";
"keyboard.ai.error.pipelineBusy" = "语音服务正忙,请稍后重试";
"keyboard.ai.error.startTimeout" = "麦克风启动超时,请重试";