feat(keyboard): add clipboard AI skills, hint keywords, and voice session fixes

Idle chips show entities with category icons; a fresh copy surfaces Reply/Summarize/Translate; abort/cancel/empty-tap no longer leave the mic stuck.
This commit is contained in:
Rocky
2026-08-13 15:47:32 +08:00
parent 9f308fadd2
commit 631617ee53
48 changed files with 1627 additions and 345 deletions
@@ -66,20 +66,44 @@ final class AIKeyboardCoordinator {
}
}
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
func submitClipboardSkill(_ skill: AIClipboardSkill) {
guard canAcceptIdleSubmit else { return }
enterIfNeeded()
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: AIHintLocaleResolver.packLocale(),
translationTargetLocaleId: state.translationTargetLocaleId
)
let resolution = AIClipboardPrompt.resolve(
instruction: instruction,
material: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
)
submitResolvedPrompt(resolution)
}
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
func submitHintCard(_ card: AIHintCard) {
switch state.aiSession.phase {
case .inactive, .idle, .failed:
break
case .preparing, .listening, .recognizing, .generating,
.ready, .awaitingSend, .inserted, .sent:
return
}
guard canAcceptIdleSubmit else { return }
enterIfNeeded()
let resolution = AIHintPool.resolvePrompt(
for: card,
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
)
submitResolvedPrompt(resolution)
}
private var canAcceptIdleSubmit: Bool {
switch state.aiSession.phase {
case .inactive, .idle, .failed:
return true
case .preparing, .listening, .recognizing, .generating,
.ready, .awaitingSend, .inserted, .sent:
return false
}
}
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
state.aiSession.fail(
@@ -132,11 +156,21 @@ final class AIKeyboardCoordinator {
state.aiSession.beginRecognizing(utteranceID: utteranceID)
}
func generatingStarted(_ utteranceID: UUID) {
state.aiSession.beginGenerating(question: "", utteranceID: utteranceID)
}
func receiveTranscript(
_ transcript: String,
utteranceID: UUID,
status: FlowResult.Status
) {
if AIClipboardPrompt.isInternalPrompt(transcript) {
if status == .rawReady {
state.aiSession.beginGenerating(question: "", utteranceID: utteranceID)
}
return
}
state.aiSession.updateTranscript(transcript, utteranceID: utteranceID)
if status == .rawReady {
state.aiSession.beginGenerating(