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:
@@ -446,6 +446,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
flowCoordinator.onAIRecognitionStarted = { [weak self] utteranceID in
|
||||
self?.aiKeyboardCoordinator.recognitionStarted(utteranceID)
|
||||
}
|
||||
flowCoordinator.onAIGeneratingStarted = { [weak self] utteranceID in
|
||||
self?.aiKeyboardCoordinator.generatingStarted(utteranceID)
|
||||
}
|
||||
flowCoordinator.onAITranscript = { [weak self] transcript, utteranceID, status in
|
||||
self?.aiKeyboardCoordinator.receiveTranscript(
|
||||
transcript,
|
||||
@@ -465,6 +468,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
flowCoordinator.onAIFailure = { [weak self] message, utteranceID in
|
||||
self?.aiKeyboardCoordinator.fail(message, utteranceID: utteranceID)
|
||||
}
|
||||
flowCoordinator.onAICancelled = { [weak self] in
|
||||
self?.state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
_ = textInserter.recoverPendingEditTransactionIfNeeded()
|
||||
|
||||
cursorDrag = CursorDragController(
|
||||
@@ -511,6 +517,9 @@ 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.openSettings = { [weak self] in self?.openHostApp() }
|
||||
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
|
||||
state.openClipboardSettings = { [weak self] in
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -61,10 +61,12 @@ final class KeyboardFlowCoordinator {
|
||||
var onAIUtterancePrepared: (UUID) -> Void = { _ in }
|
||||
var onAIRecordingStarted: (UUID) -> Void = { _ in }
|
||||
var onAIRecognitionStarted: (UUID) -> Void = { _ in }
|
||||
var onAIGeneratingStarted: (UUID) -> Void = { _ in }
|
||||
var onAITranscript: (String, UUID, FlowResult.Status) -> Void = { _, _, _ in }
|
||||
var onAIStreamingAnswer: (String, UUID) -> Void = { _, _ in }
|
||||
var onAIResult: (FlowResult) -> Void = { _ in }
|
||||
var onAIFailure: (String, UUID?) -> Void = { _, _ in }
|
||||
var onAICancelled: () -> Void = {}
|
||||
/// Utterance whose final result we already inserted (or failed). Prevents
|
||||
/// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a
|
||||
/// stale App Group snapshot still says `reason=processing`.
|
||||
@@ -414,6 +416,29 @@ final class KeyboardFlowCoordinator {
|
||||
isFlowRecording = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
if FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
||||
busyUtteranceId: busyId,
|
||||
latestResult: FlowSessionBridge.latestResult(),
|
||||
latestAck: FlowSessionBridge.latestAck()
|
||||
) {
|
||||
// Already acked, result gone, host forgot to drop the gate.
|
||||
// Abort unsticks the host. Do not await — claimTerminal already
|
||||
// ran, so abort will not write a new result. Remember the id
|
||||
// so this same refresh cannot re-adopt before the host poll.
|
||||
writeCommand(.abort)
|
||||
lastConsumedUtteranceId = busyId
|
||||
lastStoppedUtteranceId = busyId
|
||||
isAwaitingFlowResult = false
|
||||
currentUtteranceId = nil
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
traceState(
|
||||
"adoptHostBusy.staleProcessingReleased",
|
||||
extra: "utterance=\(busyId.uuidString.prefix(8))"
|
||||
)
|
||||
return
|
||||
}
|
||||
// Missing result with no matching ack is live ASR/LLM — wait.
|
||||
state.phase = .processing
|
||||
if state.lastTranscript.isEmpty {
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||
@@ -557,6 +582,12 @@ final class KeyboardFlowCoordinator {
|
||||
case .requestingPermissions:
|
||||
break
|
||||
case .idle, .denied, .error:
|
||||
// A second tap while still waiting for the host must cancel the
|
||||
// pending start instead of stacking another utterance.
|
||||
if currentUtteranceRequest != nil {
|
||||
cancelCurrentDictation()
|
||||
return
|
||||
}
|
||||
_ = startUtterance(.dictation)
|
||||
case .processing:
|
||||
break
|
||||
@@ -655,34 +686,8 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
func cancelAIRecording() {
|
||||
guard currentUtteranceRequest?.isAIQuestion == true else { return }
|
||||
recordWhenHostReady = false
|
||||
recordAfterHandoff = false
|
||||
isPendingFlowStart = false
|
||||
flowStartDeadline = 0
|
||||
coldStartDebouncer.reset()
|
||||
stopHostReadyWait()
|
||||
stopFlowWatchdog()
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
state.level = 0
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
|
||||
guard let utteranceID = currentUtteranceId,
|
||||
isFlowRecording || isAwaitingFlowResult else {
|
||||
clearUnissuedUtterance()
|
||||
isFlowRecording = false
|
||||
isAwaitingFlowResult = false
|
||||
recomputeMicVoiceAvailability()
|
||||
return
|
||||
}
|
||||
|
||||
cancelledAIUtteranceIDs.insert(utteranceID)
|
||||
writeCommand(.abort)
|
||||
isFlowRecording = false
|
||||
isAwaitingFlowResult = true
|
||||
startFlowResultWatchdog()
|
||||
recomputeMicVoiceAvailability()
|
||||
prepareLocalCancel()
|
||||
beginAwaitingAbort(tracking: &cancelledAIUtteranceIDs)
|
||||
}
|
||||
|
||||
func endAIConversation(_ conversationID: UUID) {
|
||||
@@ -708,11 +713,27 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
|
||||
func cancelCurrentDictation() {
|
||||
guard currentUtteranceRequest?.isEdit != true,
|
||||
currentUtteranceRequest?.isAIQuestion != true else {
|
||||
if currentUtteranceRequest?.isAIQuestion == true {
|
||||
cancelAIRecording()
|
||||
return
|
||||
}
|
||||
guard currentUtteranceRequest?.isEdit != true else { return }
|
||||
|
||||
prepareLocalCancel()
|
||||
let hadIssuedTransport = currentUtteranceId != nil
|
||||
&& (isFlowRecording || isAwaitingFlowResult)
|
||||
beginAwaitingAbort(tracking: &cancelledDictationUtteranceIDs)
|
||||
traceState(
|
||||
"dictation.cancelled",
|
||||
extra: hadIssuedTransport
|
||||
? "utterance=\(currentUtteranceId?.uuidString.prefix(8) ?? "none")"
|
||||
: "transport=localOnly"
|
||||
)
|
||||
}
|
||||
|
||||
/// Stop local wait/prime timers before an abort. Does not change phase;
|
||||
/// `beginAwaitingAbort` keeps `.processing` until the host acks.
|
||||
private func prepareLocalCancel() {
|
||||
recordWhenHostReady = false
|
||||
recordAfterHandoff = false
|
||||
isPendingFlowStart = false
|
||||
@@ -723,29 +744,37 @@ final class KeyboardFlowCoordinator {
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
state.level = 0
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
}
|
||||
|
||||
/// Keep the cancel chrome (X + white mic) until the host finishes abort.
|
||||
private func beginAwaitingAbort(tracking cancelledIDs: inout Set<UUID>) {
|
||||
guard let utteranceID = currentUtteranceId,
|
||||
isFlowRecording || isAwaitingFlowResult else {
|
||||
clearUnissuedUtterance()
|
||||
isFlowRecording = false
|
||||
isAwaitingFlowResult = false
|
||||
recomputeMicVoiceAvailability()
|
||||
traceState("dictation.cancelled", extra: "transport=localOnly")
|
||||
finishLocalCancel()
|
||||
return
|
||||
}
|
||||
|
||||
cancelledDictationUtteranceIDs.insert(utteranceID)
|
||||
if cancelledIDs.contains(utteranceID), isAwaitingFlowResult {
|
||||
state.phase = .processing
|
||||
recomputeMicVoiceAvailability()
|
||||
return
|
||||
}
|
||||
cancelledIDs.insert(utteranceID)
|
||||
writeCommand(.abort)
|
||||
isFlowRecording = false
|
||||
isAwaitingFlowResult = true
|
||||
state.phase = .processing
|
||||
startFlowResultWatchdog()
|
||||
recomputeMicVoiceAvailability()
|
||||
traceState(
|
||||
"dictation.cancelled",
|
||||
extra: "utterance=\(utteranceID.uuidString.prefix(8))"
|
||||
)
|
||||
}
|
||||
|
||||
private func finishLocalCancel() {
|
||||
clearUnissuedUtterance()
|
||||
isFlowRecording = false
|
||||
isAwaitingFlowResult = false
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
recomputeMicVoiceAvailability()
|
||||
}
|
||||
|
||||
func abortEditRecording() {
|
||||
@@ -1085,9 +1114,11 @@ final class KeyboardFlowCoordinator {
|
||||
if isFlowRecording {
|
||||
writeCommand(.abort)
|
||||
ExtensionScreenWakeLock.release()
|
||||
// Remember the aborted id so the next keyboard open cannot
|
||||
// re-adopt a lagging host snapshot as 「识别中」.
|
||||
lastStoppedUtteranceId = currentUtteranceId
|
||||
}
|
||||
currentUtteranceId = nil
|
||||
lastStoppedUtteranceId = nil
|
||||
isFlowRecording = false
|
||||
isPendingFlowStart = false
|
||||
recordAfterHandoff = false
|
||||
@@ -1174,6 +1205,10 @@ final class KeyboardFlowCoordinator {
|
||||
consumeCancelledDictationResultIfNeeded(result) {
|
||||
return
|
||||
}
|
||||
if let result = matchingResult(),
|
||||
consumeDiscardedEmptyResultIfNeeded(result) {
|
||||
return
|
||||
}
|
||||
if let result = matchingResult(),
|
||||
consumeCancelledEditResultIfNeeded(result) {
|
||||
return
|
||||
@@ -1311,6 +1346,42 @@ final class KeyboardFlowCoordinator {
|
||||
return true
|
||||
}
|
||||
|
||||
private func consumeDiscardedEmptyResultIfNeeded(_ result: FlowResult) -> Bool {
|
||||
guard result.errorKind == .discardedEmpty,
|
||||
result.status == .aborted || isTerminalFailure(result) else {
|
||||
return false
|
||||
}
|
||||
FlowSessionBridge.writeAck(
|
||||
FlowAck(
|
||||
sessionId: result.sessionId,
|
||||
utteranceId: result.utteranceId,
|
||||
commandSeq: result.commandSeq,
|
||||
hostGeneration: result.hostGeneration,
|
||||
revision: result.revision,
|
||||
deliveryOutcome: .rejected
|
||||
)
|
||||
)
|
||||
lastConsumedUtteranceId = result.utteranceId
|
||||
lastStoppedUtteranceId = nil
|
||||
stopUtteranceCountdown()
|
||||
stopFlowWatchdog()
|
||||
ExtensionScreenWakeLock.release()
|
||||
let wasAI = currentUtteranceRequest?.isAIQuestion == true
|
||||
resetEditTransportState()
|
||||
state.level = 0
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
recomputeMicVoiceAvailability()
|
||||
if wasAI {
|
||||
onAICancelled()
|
||||
}
|
||||
traceState(
|
||||
"utterance.discardedEmptyTap",
|
||||
extra: "utterance=\(result.utteranceId.uuidString.prefix(8))"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private func consumeCancelledEditResultIfNeeded(_ result: FlowResult) -> Bool {
|
||||
guard cancelledEditUtteranceIDs.contains(result.utteranceId),
|
||||
result.status == .final || isTerminalFailure(result) else {
|
||||
@@ -1754,10 +1825,10 @@ final class KeyboardFlowCoordinator {
|
||||
writeSubmitAIQuestion(question)
|
||||
isFlowRecording = false
|
||||
isAwaitingFlowResult = true
|
||||
state.lastTranscript = question
|
||||
state.lastTranscript = ""
|
||||
state.phase = .processing
|
||||
if let currentUtteranceId {
|
||||
onAIRecognitionStarted(currentUtteranceId)
|
||||
onAIGeneratingStarted(currentUtteranceId)
|
||||
}
|
||||
startFlowResultWatchdog()
|
||||
recomputeMicVoiceAvailability()
|
||||
@@ -1935,6 +2006,11 @@ final class KeyboardFlowCoordinator {
|
||||
if (result.status == .partial || result.status == .rawReady),
|
||||
let partial = result.text,
|
||||
!partial.isEmpty {
|
||||
if result.resolvedUtteranceMode == .aiQuestion,
|
||||
AIClipboardPrompt.isInternalPrompt(partial) {
|
||||
onAIGeneratingStarted(result.utteranceId)
|
||||
return
|
||||
}
|
||||
state.lastTranscript = partial
|
||||
if result.resolvedUtteranceMode == .aiQuestion {
|
||||
onAITranscript(partial, result.utteranceId, result.status)
|
||||
@@ -1982,6 +2058,10 @@ final class KeyboardFlowCoordinator {
|
||||
self.consumeCancelledDictationResultIfNeeded(result) {
|
||||
return
|
||||
}
|
||||
if let result = self.matchingResult(),
|
||||
self.consumeDiscardedEmptyResultIfNeeded(result) {
|
||||
return
|
||||
}
|
||||
if let result = self.matchingResult(),
|
||||
self.consumeCancelledEditResultIfNeeded(result) {
|
||||
return
|
||||
|
||||
@@ -15,6 +15,7 @@ struct AIKeyboardView: View {
|
||||
static let actionButtonMaxWidth: CGFloat = 150
|
||||
static let statusHeight: CGFloat = 20
|
||||
static let carouselInterval: TimeInterval = 4
|
||||
static let skillButtonSize: CGFloat = 52
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@@ -61,6 +62,7 @@ struct AIKeyboardView: View {
|
||||
// Reduce Motion stops the rotation, not the data: a card whose
|
||||
// clipboard window has closed must still leave the carousel.
|
||||
reloadHintPool(resetBag: false)
|
||||
guard !showsClipboardSkills else { return }
|
||||
if reduceMotion, let hint = currentHint,
|
||||
poolCards.contains(where: { $0.id == hint.id }) {
|
||||
return
|
||||
@@ -127,8 +129,13 @@ struct AIKeyboardView: View {
|
||||
private var answerArea: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
if showsPlaceholder {
|
||||
hintCarousel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
if showsClipboardSkills {
|
||||
clipboardSkillRow
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
hintCarousel
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical) {
|
||||
@@ -173,30 +180,82 @@ struct AIKeyboardView: View {
|
||||
guard let hint = currentHint else { return }
|
||||
state.submitAIHint(hint)
|
||||
} label: {
|
||||
Text(currentHint?.displayText ?? ExtL10n.string("keyboard.ai.placeholder"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.opacity(hintOpacity)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
HStack(spacing: 6) {
|
||||
if let hint = currentHint {
|
||||
Image(systemName: hint.visualKind.systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary.opacity(0.55))
|
||||
}
|
||||
Text(currentHint.map(\.resolvedDisplayText) ?? ExtL10n.string("keyboard.ai.placeholder"))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 44)
|
||||
.opacity(hintOpacity)
|
||||
.glassEffect(.regular.interactive(), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize()
|
||||
// A busy session already owns the surface; the status line explains a
|
||||
// missing LLM. Both keep the hint from being a tap with no outcome.
|
||||
.disabled(currentHint == nil || !state.aiServiceAvailable || state.aiSession.isBusy)
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
currentHint.map {
|
||||
"\(ExtL10n.string("keyboard.ai.hintA11yPrefix"))\($0.displayText)"
|
||||
"\(ExtL10n.string("keyboard.ai.hintA11yPrefix"))\($0.resolvedDisplayText)"
|
||||
} ?? ExtL10n.string("keyboard.ai.placeholder")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var clipboardSkillRow: some View {
|
||||
HStack(spacing: Spacing.lg) {
|
||||
ForEach(AIClipboardSkillCatalog.visible()) { skill in
|
||||
Button {
|
||||
state.submitAIClipboardSkill(skill)
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: skill.systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary.opacity(0.85))
|
||||
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
|
||||
.glassEffect(.regular.interactive(), in: Circle())
|
||||
Text(clipboardSkillTitle(skill))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
|
||||
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
/// Translate follows the keyboard target; Reply / Summarize stay static.
|
||||
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
|
||||
if skill.id == AIClipboardSkillCatalog.translateID {
|
||||
return AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: state.translationTargetLocaleId,
|
||||
uiLanguage: AppGroupStore().uiLanguage
|
||||
)
|
||||
}
|
||||
return ExtL10n.string(skill.titleKey)
|
||||
}
|
||||
|
||||
/// Copy-then-30s window: skill chips replace the rotating hint.
|
||||
private var showsClipboardSkills: Bool {
|
||||
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
|
||||
return AIHintPool.isClipboardSkillWindowActive(
|
||||
clipboardHistoryEnabled: true,
|
||||
newestClipboard: clipboardHistory.newestEntry
|
||||
)
|
||||
}
|
||||
|
||||
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
|
||||
private var showsPlaceholder: Bool {
|
||||
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
|
||||
@@ -367,16 +426,20 @@ struct AIKeyboardView: View {
|
||||
? ExtL10n.string("keyboard.ai.listening")
|
||||
: state.aiSession.transcript
|
||||
case .recognizing:
|
||||
return state.aiSession.transcript.isEmpty
|
||||
? ExtL10n.string("keyboard.ai.recognizing")
|
||||
: state.aiSession.transcript
|
||||
if state.aiSession.transcript.isEmpty
|
||||
|| AIClipboardPrompt.isInternalPrompt(state.aiSession.transcript) {
|
||||
return ExtL10n.string("keyboard.ai.recognizing")
|
||||
}
|
||||
return state.aiSession.transcript
|
||||
case .generating:
|
||||
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
||||
return ExtL10n.string("keyboard.ai.generating")
|
||||
}
|
||||
return state.aiSession.transcript.isEmpty
|
||||
? ExtL10n.string("keyboard.ai.thinking")
|
||||
: state.aiSession.transcript
|
||||
if state.aiSession.transcript.isEmpty
|
||||
|| AIClipboardPrompt.isInternalPrompt(state.aiSession.transcript) {
|
||||
return ExtL10n.string("keyboard.ai.thinking")
|
||||
}
|
||||
return state.aiSession.transcript
|
||||
case .failed:
|
||||
return ExtL10n.string("keyboard.ai.error.requestFailed")
|
||||
}
|
||||
@@ -391,19 +454,18 @@ struct AIKeyboardView: View {
|
||||
// MARK: - Carousel
|
||||
|
||||
/// Rebuild the pool and show a card right away, without a fade.
|
||||
/// Skip advancing the chip while clipboard skills own the surface, so a
|
||||
/// leftover clipboard sentence cannot replace the three buttons.
|
||||
private func resetCarousel() {
|
||||
reloadHintPool(resetBag: true)
|
||||
guard !showsClipboardSkills else { return }
|
||||
showNextHint(animated: false)
|
||||
}
|
||||
|
||||
private func reloadHintPool(resetBag: Bool) {
|
||||
let locale = AIHintLocaleResolver.packLocale()
|
||||
let pack = AIHintStore.resolvedPack(locale: locale)
|
||||
poolCards = AIHintPool.activeCards(
|
||||
pack: pack,
|
||||
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
|
||||
newestClipboard: clipboardHistory.newestEntry
|
||||
)
|
||||
poolCards = AIHintPool.activeCards(pack: pack)
|
||||
if resetBag {
|
||||
carouselBag.reset()
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ import OSGKeyboardShared
|
||||
private enum KeyboardLayoutMetrics {
|
||||
static let micSize: CGFloat = 121
|
||||
static let micToButtonGap: CGFloat = 8
|
||||
/// Square undo key beside the mic (outer edge, aligned with delete).
|
||||
static let undoButtonSize: CGFloat = 44
|
||||
/// Circular undo / translation keys beside the mic (outer edge).
|
||||
static let undoButtonSize: CGFloat = 52
|
||||
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
|
||||
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
|
||||
/// Gap between the top control row and the transcript / hint line.
|
||||
@@ -419,7 +419,7 @@ public struct KeyboardRootView: View {
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
/// Square undo key on the outer drag pad — same chrome / haptic / click
|
||||
/// Circular undo key on the outer drag pad — same chrome / haptic / click
|
||||
/// as space & return. Vertically matches the mic disc.
|
||||
private func undoButton(disabled: Bool, visible: Bool) -> some View {
|
||||
RectangularToolbarButton(
|
||||
@@ -427,6 +427,7 @@ public struct KeyboardRootView: View {
|
||||
label: ExtL10n.string("keyboard.undoA11y"),
|
||||
disabled: disabled,
|
||||
usesLiquidGlass: true,
|
||||
usesCircleGlass: true,
|
||||
hapticIntensity: state.keyboardHapticIntensity
|
||||
) {
|
||||
state.undoLastInsertion()
|
||||
|
||||
@@ -280,25 +280,18 @@ struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
// Match the adjacent undo key: 44×44 rounded Liquid Glass control.
|
||||
ZStack {
|
||||
Color.clear
|
||||
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
isEnabled
|
||||
? palette.accent
|
||||
: palette.textSecondary
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.glassEffect(
|
||||
.regular.interactive(),
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
.contentShape(Circle())
|
||||
.glassEffect(.regular.interactive(), in: Circle())
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
||||
|
||||
@@ -10,6 +10,8 @@ import OSGKeyboardShared
|
||||
|
||||
private enum ToolbarButtonMetrics {
|
||||
static let iconSize: CGFloat = 14
|
||||
/// Slightly larger glyph so a 52 pt circular glass key does not look sparse.
|
||||
static let circleIconSize: CGFloat = 17
|
||||
static let titleSize: CGFloat = 16
|
||||
static let cornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
|
||||
static let spaceBarCapsuleWidth: CGFloat = 31
|
||||
@@ -201,6 +203,7 @@ struct RectangularToolbarButton: View {
|
||||
let disabled: Bool
|
||||
let isSend: Bool
|
||||
let usesLiquidGlass: Bool
|
||||
let usesCircleGlass: Bool
|
||||
/// Settings → General → Haptics; space / return use `.action` role.
|
||||
var hapticIntensity: KeyboardHapticIntensity = .off
|
||||
let action: () -> Void
|
||||
@@ -210,6 +213,7 @@ struct RectangularToolbarButton: View {
|
||||
label: String,
|
||||
disabled: Bool = false,
|
||||
usesLiquidGlass: Bool = false,
|
||||
usesCircleGlass: Bool = false,
|
||||
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
@@ -220,6 +224,7 @@ struct RectangularToolbarButton: View {
|
||||
self.disabled = disabled
|
||||
self.isSend = false
|
||||
self.usesLiquidGlass = usesLiquidGlass
|
||||
self.usesCircleGlass = usesCircleGlass
|
||||
self.hapticIntensity = hapticIntensity
|
||||
self.action = action
|
||||
}
|
||||
@@ -239,6 +244,7 @@ struct RectangularToolbarButton: View {
|
||||
self.disabled = disabled
|
||||
self.isSend = isSend
|
||||
self.usesLiquidGlass = usesLiquidGlass
|
||||
self.usesCircleGlass = false
|
||||
self.hapticIntensity = hapticIntensity
|
||||
self.action = action
|
||||
self.title = title
|
||||
@@ -259,6 +265,7 @@ struct RectangularToolbarButton: View {
|
||||
self.disabled = disabled
|
||||
self.isSend = false
|
||||
self.usesLiquidGlass = usesLiquidGlass
|
||||
self.usesCircleGlass = false
|
||||
self.hapticIntensity = hapticIntensity
|
||||
self.action = action
|
||||
}
|
||||
@@ -267,7 +274,7 @@ struct RectangularToolbarButton: View {
|
||||
|
||||
var body: some View {
|
||||
buttonSurface
|
||||
.contentShape(Rectangle())
|
||||
.contentShape(usesCircleGlass ? AnyShape(Circle()) : AnyShape(Rectangle()))
|
||||
.gesture(pressGesture)
|
||||
.opacity(disabled ? 0.38 : 1)
|
||||
.allowsHitTesting(!disabled)
|
||||
@@ -282,13 +289,7 @@ struct RectangularToolbarButton: View {
|
||||
Color.clear
|
||||
buttonContent
|
||||
}
|
||||
.glassEffect(
|
||||
.regular.interactive(),
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: ToolbarButtonMetrics.cornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
.modifier(ToolbarLiquidGlass(isCircle: usesCircleGlass))
|
||||
// The custom press gesture fires on touch-down; mirror that state
|
||||
// visually while Liquid Glass supplies its native light response.
|
||||
.scaleEffect(isPressing ? 0.97 : 1)
|
||||
@@ -312,7 +313,12 @@ struct RectangularToolbarButton: View {
|
||||
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
|
||||
} else if let systemName {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
|
||||
.font(.system(
|
||||
size: usesCircleGlass
|
||||
? ToolbarButtonMetrics.circleIconSize
|
||||
: ToolbarButtonMetrics.iconSize,
|
||||
weight: .semibold
|
||||
))
|
||||
.foregroundStyle(buttonForeground)
|
||||
} else if let title {
|
||||
Text(title)
|
||||
@@ -340,3 +346,21 @@ struct RectangularToolbarButton: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolbarLiquidGlass: ViewModifier {
|
||||
let isCircle: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if isCircle {
|
||||
content.glassEffect(.regular.interactive(), in: Circle())
|
||||
} else {
|
||||
content.glassEffect(
|
||||
.regular.interactive(),
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: ToolbarButtonMetrics.cornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,3 +294,6 @@
|
||||
"keyboard.ai.error.requestTimeout" = "AI response timed out. Try again";
|
||||
"keyboard.ai.error.requestFailed" = "AI response failed. Try again";
|
||||
"keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
|
||||
"keyboard.ai.skill.reply" = "Reply";
|
||||
"keyboard.ai.skill.summarize" = "Summarize";
|
||||
"keyboard.ai.skill.translate" = "Translate";
|
||||
|
||||
@@ -294,3 +294,6 @@
|
||||
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
|
||||
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
|
||||
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
|
||||
"keyboard.ai.skill.reply" = "回复";
|
||||
"keyboard.ai.skill.summarize" = "总结";
|
||||
"keyboard.ai.skill.translate" = "翻译";
|
||||
|
||||
Reference in New Issue
Block a user