0a60006d2d
Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
577 lines
22 KiB
Swift
577 lines
22 KiB
Swift
// AIKeyboardView.swift
|
|
// OSGKeyboard · Keyboard Extension
|
|
//
|
|
// Product voice-to-AI conversation surface. The latest answer remains visible
|
|
// while a follow-up runs and is inserted only through the explicit Send action.
|
|
|
|
import SwiftUI
|
|
import OSGKeyboardShared
|
|
|
|
struct AIKeyboardView: View {
|
|
private enum Layout {
|
|
static let contentHeight: CGFloat = 174
|
|
static let actionRowHeight: CGFloat = 55
|
|
static let actionButtonHeight: CGFloat = 50
|
|
static let actionButtonMaxWidth: CGFloat = 150
|
|
static let statusHeight: CGFloat = 20
|
|
static let carouselInterval: TimeInterval = 4
|
|
static let skillButtonSize: CGFloat = 48
|
|
static let skillsPerRow = 4
|
|
static let skillRowSpacing: CGFloat = 8
|
|
static let skillCellSpacing: CGFloat = 16
|
|
static let skillCellWidth: CGFloat = 64
|
|
}
|
|
|
|
#if DEBUG
|
|
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
|
|
static var debugPreviewSkills: [AIClipboardSkill]?
|
|
#endif
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@ObservedObject var state: KeyboardState
|
|
@ObservedObject var typing: TypingSessionController
|
|
/// A copy made while the keyboard is visible must reach the carousel
|
|
/// immediately, not on the next rotation tick.
|
|
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
|
|
let onInsert: (String) -> Void
|
|
|
|
@State private var currentHint: AIHintCard?
|
|
@State private var hintOpacity: Double = 1
|
|
@State private var carouselBag = AIHintCarouselBag()
|
|
@State private var poolCards: [AIHintCard] = []
|
|
|
|
private var palette: ThemePalette {
|
|
colorScheme == .dark ? Palette.dark : Palette.light
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
topBar.frame(height: KeyboardTopBarMetrics.height)
|
|
answerArea.frame(height: resolvedAnswerHeight)
|
|
actionRow.frame(height: Layout.actionRowHeight)
|
|
}
|
|
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
|
.padding(.vertical, 4)
|
|
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: resolvedHeight)
|
|
.environment(\.themePalette, palette)
|
|
.onAppear { resetCarousel() }
|
|
.onChange(of: state.aiSession.phase) { _, phase in
|
|
guard phase == .idle || phase == .failed else { return }
|
|
resetCarousel()
|
|
}
|
|
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
|
|
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
|
|
.onChange(of: state.enabledClipboardSkillIDs) { _, _ in resetCarousel() }
|
|
.onChange(of: state.skillTipText) { _, tip in
|
|
guard let tip, !tip.isEmpty else { return }
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 2_800_000_000)
|
|
if state.skillTipText == tip {
|
|
state.skillTipText = nil
|
|
}
|
|
}
|
|
}
|
|
.animation(Motion.soft, value: state.skillTipText)
|
|
.onReceive(
|
|
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
|
|
) { _ in
|
|
guard showsPlaceholder else { return }
|
|
// 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
|
|
}
|
|
showNextHint(animated: !reduceMotion)
|
|
}
|
|
}
|
|
|
|
private var resolvedHeight: CGFloat {
|
|
TypingSurfaceMetrics.contentHeight(
|
|
isIPad: state.usesIPadLayoutMetrics,
|
|
width: state.layoutWidth
|
|
)
|
|
}
|
|
|
|
private var resolvedAnswerHeight: CGFloat {
|
|
max(
|
|
Layout.contentHeight,
|
|
resolvedHeight
|
|
- KeyboardTopBarMetrics.height
|
|
- Layout.actionRowHeight
|
|
- 8
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var topBar: some View {
|
|
if state.canCancelAIInput {
|
|
HStack(spacing: Spacing.xs) {
|
|
KeyboardBrandLogo(action: state.openSettings)
|
|
Spacer(minLength: 0)
|
|
KeyboardCancelButton(
|
|
action: state.cancelAIInput,
|
|
accessibilityLabel: ExtL10n.text("keyboard.ai.cancel"),
|
|
accessibilityHint: ExtL10n.text("keyboard.ai.cancelHint")
|
|
)
|
|
}
|
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
|
} else if state.canShowClipboardEntry,
|
|
let suggestion = state.clipboardSuggestionText,
|
|
!suggestion.isEmpty {
|
|
// Replaces logo + capsule tabs until dismissed.
|
|
ClipboardSuggestionBar(
|
|
text: suggestion,
|
|
onInsert: { state.insertClipboardText(suggestion) },
|
|
onDismiss: state.dismissClipboardSuggestion
|
|
)
|
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
|
} else {
|
|
HStack(spacing: Spacing.xs) {
|
|
KeyboardBrandLogo(action: state.openSettings)
|
|
Spacer(minLength: 0)
|
|
KeyboardTopControls(
|
|
state: state,
|
|
typing: typing,
|
|
palette: palette,
|
|
onInsert: onInsert
|
|
)
|
|
}
|
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
|
}
|
|
}
|
|
|
|
private var answerArea: some View {
|
|
ZStack(alignment: .bottom) {
|
|
if showsPlaceholder {
|
|
if showsClipboardSkills {
|
|
clipboardSkillRow
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
hintCarousel
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
} else {
|
|
ScrollViewReader { proxy in
|
|
ScrollView(.vertical) {
|
|
Group {
|
|
if let draft = state.aiSession.draftAnswerText,
|
|
!draft.isEmpty {
|
|
Text(draft)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.id("ai-draft")
|
|
} else if let answer = state.aiSession.answer {
|
|
Text(answer.text)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.id(answer.id)
|
|
}
|
|
}
|
|
.font(TypeStyle.body)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
.padding(.horizontal, Spacing.md)
|
|
.padding(.top, Spacing.sm)
|
|
.padding(.bottom, Layout.statusHeight + Spacing.sm)
|
|
}
|
|
.scrollIndicators(.visible)
|
|
.onChange(of: state.aiSession.answer?.id) { _, answerID in
|
|
guard let answerID else { return }
|
|
proxy.scrollTo(answerID, anchor: .top)
|
|
}
|
|
.onChange(of: state.aiSession.draftAnswerText) { _, draft in
|
|
guard let draft, !draft.isEmpty else { return }
|
|
proxy.scrollTo("ai-draft", anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
|
|
statusLine
|
|
.frame(height: Layout.statusHeight)
|
|
.padding(.horizontal, Spacing.md)
|
|
|
|
if let tip = state.skillTipText, !tip.isEmpty {
|
|
Text(tip)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.glassEffect(.regular, in: Capsule())
|
|
.padding(.bottom, Layout.statusHeight + Spacing.sm)
|
|
.transition(.opacity)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var hintCarousel: some View {
|
|
Button {
|
|
guard let hint = currentHint else { return }
|
|
state.submitAIHint(hint)
|
|
} label: {
|
|
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.resolvedDisplayText)"
|
|
} ?? ExtL10n.string("keyboard.ai.placeholder")
|
|
)
|
|
)
|
|
}
|
|
|
|
private var clipboardSkillRow: some View {
|
|
let rows = skillRows(from: visibleClipboardSkills)
|
|
return VStack(spacing: 0) {
|
|
Spacer(minLength: 0)
|
|
VStack(spacing: Layout.skillRowSpacing) {
|
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, rowSkills in
|
|
HStack(spacing: Layout.skillCellSpacing) {
|
|
ForEach(rowSkills) { skill in
|
|
skillChip(skill)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func skillRows(from skills: [AIClipboardSkill]) -> [[AIClipboardSkill]] {
|
|
stride(from: 0, to: skills.count, by: Layout.skillsPerRow).map { start in
|
|
Array(skills[start ..< min(start + Layout.skillsPerRow, skills.count)])
|
|
}
|
|
}
|
|
|
|
private func skillChip(_ skill: AIClipboardSkill) -> some View {
|
|
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)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.frame(width: Layout.skillCellWidth)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
|
|
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
|
}
|
|
|
|
/// 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
|
|
)
|
|
}
|
|
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!custom.isEmpty {
|
|
return custom
|
|
}
|
|
return ExtL10n.string(skill.titleKey)
|
|
}
|
|
|
|
/// Copy-then-30s window: skill chips replace the rotating hint.
|
|
private var showsClipboardSkills: Bool {
|
|
#if DEBUG
|
|
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
|
|
return showsPlaceholder
|
|
}
|
|
#endif
|
|
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
|
|
guard !visibleClipboardSkills.isEmpty else { return false }
|
|
return AIHintPool.isClipboardSkillWindowActive(
|
|
clipboardHistoryEnabled: true,
|
|
newestClipboard: clipboardHistory.newestEntry
|
|
)
|
|
}
|
|
|
|
private var visibleClipboardSkills: [AIClipboardSkill] {
|
|
#if DEBUG
|
|
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
|
|
return preview
|
|
}
|
|
#endif
|
|
return AIClipboardSkillCatalog.visible(
|
|
enabledIDs: state.enabledClipboardSkillIDs,
|
|
userCatalog: AppGroupStore().agentUserSkillCatalog
|
|
)
|
|
}
|
|
|
|
/// 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)
|
|
return !hasDraft && state.aiSession.answer == nil
|
|
}
|
|
|
|
private var statusLine: some View {
|
|
// Loading spinner lives on the mic button only — avoid a second
|
|
// ProgressView beside the status / draft caption.
|
|
Text(statusText)
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(
|
|
state.aiSession.phase == .failed
|
|
? palette.warning
|
|
: palette.textSecondary
|
|
)
|
|
.lineLimit(1)
|
|
.truncationMode(.head)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
}
|
|
|
|
private var actionRow: some View {
|
|
HStack(spacing: Spacing.sm) {
|
|
aiMicrophoneButton
|
|
sendButton
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var aiMicrophoneButton: some View {
|
|
Button(action: state.tapAIMic) {
|
|
ZStack {
|
|
Color.clear
|
|
if state.aiSession.phase == .listening {
|
|
Capsule()
|
|
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
|
|
.scaleEffect(1 + min(max(state.level, 0), 1) * 0.08)
|
|
.animation(Motion.soft, value: state.level)
|
|
}
|
|
microphoneContent
|
|
}
|
|
.frame(
|
|
maxWidth: Layout.actionButtonMaxWidth,
|
|
minHeight: Layout.actionButtonHeight,
|
|
maxHeight: Layout.actionButtonHeight
|
|
)
|
|
// 实心填充、无外扩阴影:避免玻璃投影被键盘底边裁切。
|
|
.background(palette.accent, in: Capsule())
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(microphoneDisabled)
|
|
.accessibilityLabel(ExtL10n.text(microphoneAccessibilityKey))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var microphoneContent: some View {
|
|
switch state.aiSession.phase {
|
|
case .listening:
|
|
WaveformView(
|
|
level: state.level,
|
|
barCount: 7,
|
|
color: .white,
|
|
active: true
|
|
)
|
|
.frame(width: 35, height: 22)
|
|
.clipped()
|
|
case .preparing, .recognizing, .generating:
|
|
ProgressView().tint(.white)
|
|
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
|
Image(systemName: "mic.fill")
|
|
.font(.system(size: 21, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
|
|
private var sendButton: some View {
|
|
Button(action: state.sendAIAnswer) {
|
|
HStack(spacing: Spacing.xs) {
|
|
Image(systemName: answerActionSystemName)
|
|
Text(answerActionTitle)
|
|
}
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(answerActionForeground)
|
|
.frame(
|
|
maxWidth: Layout.actionButtonMaxWidth,
|
|
minHeight: Layout.actionButtonHeight,
|
|
maxHeight: Layout.actionButtonHeight
|
|
)
|
|
// 实心填充、无外扩阴影:避免玻璃投影被键盘底边裁切。
|
|
.background(answerActionFill, in: Capsule())
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!state.aiSession.canPerformAnswerAction)
|
|
.accessibilityLabel(Text(answerActionTitle))
|
|
.accessibilityHint(ExtL10n.text("keyboard.ai.sendA11y"))
|
|
}
|
|
|
|
private var answerActionTitle: String {
|
|
switch state.aiSession.phase {
|
|
case .awaitingSend:
|
|
return ExtL10n.string("common.send")
|
|
case .inserted:
|
|
return ExtL10n.string("keyboard.ai.inserted")
|
|
case .sent:
|
|
return ExtL10n.string("keyboard.ai.sent")
|
|
case .inactive, .idle, .preparing, .listening, .recognizing,
|
|
.generating, .ready, .failed:
|
|
return ExtL10n.string("keyboard.ai.insert")
|
|
}
|
|
}
|
|
|
|
private var answerActionSystemName: String {
|
|
switch state.aiSession.phase {
|
|
case .awaitingSend:
|
|
return "paperplane.fill"
|
|
case .inserted, .sent:
|
|
return "checkmark"
|
|
case .inactive, .idle, .preparing, .listening, .recognizing,
|
|
.generating, .ready, .failed:
|
|
return "plus"
|
|
}
|
|
}
|
|
|
|
private var answerActionFill: Color {
|
|
guard state.aiSession.canPerformAnswerAction else {
|
|
return palette.surfaceElevated.opacity(0.55)
|
|
}
|
|
return state.aiSession.canSend
|
|
? palette.accent
|
|
: palette.surfaceElevated
|
|
}
|
|
|
|
private var answerActionForeground: Color {
|
|
guard state.aiSession.canPerformAnswerAction else {
|
|
return palette.textTertiary
|
|
}
|
|
return state.aiSession.canSend
|
|
? .white
|
|
: NativeKeyboardKeyColors.text(for: colorScheme)
|
|
}
|
|
|
|
private var microphoneDisabled: Bool {
|
|
switch state.aiSession.phase {
|
|
case .preparing, .recognizing, .generating:
|
|
return true
|
|
case .inactive, .idle, .listening, .ready, .awaitingSend,
|
|
.inserted, .sent, .failed:
|
|
return state.micDisabled || !state.aiServiceAvailable
|
|
}
|
|
}
|
|
|
|
private var statusText: String {
|
|
if let error = state.aiSession.errorMessage, !error.isEmpty {
|
|
return error
|
|
}
|
|
if !state.aiServiceAvailable {
|
|
return ExtL10n.string("keyboard.ai.error.missingAPIKey")
|
|
}
|
|
switch state.aiSession.phase {
|
|
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent:
|
|
return ""
|
|
case .preparing:
|
|
return ExtL10n.string("keyboard.placeholder.preparing")
|
|
case .listening:
|
|
return state.aiSession.transcript.isEmpty
|
|
? ExtL10n.string("keyboard.ai.listening")
|
|
: state.aiSession.transcript
|
|
case .recognizing:
|
|
if state.aiSession.transcript.isEmpty
|
|
|| AIClipboardPrompt.isInternalPrompt(state.aiSession.transcript) {
|
|
return ExtL10n.string("keyboard.ai.recognizing")
|
|
}
|
|
return state.aiSession.transcript
|
|
case .generating:
|
|
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
|
|
return ExtL10n.string("keyboard.ai.skill.extracting")
|
|
}
|
|
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractEventsID {
|
|
return ExtL10n.string("keyboard.ai.skill.extractingEvents")
|
|
}
|
|
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.navigateID {
|
|
return ExtL10n.string("keyboard.ai.skill.extractingAddress")
|
|
}
|
|
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
|
|
return ExtL10n.string("keyboard.ai.generating")
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
private var microphoneAccessibilityKey: String {
|
|
state.aiSession.phase == .listening
|
|
? "keyboard.ai.stopA11y"
|
|
: "keyboard.ai.startA11y"
|
|
}
|
|
|
|
// 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)
|
|
if resetBag {
|
|
carouselBag.reset()
|
|
}
|
|
}
|
|
|
|
private func showNextHint(animated: Bool) {
|
|
guard let next = carouselBag.next(from: poolCards) else {
|
|
currentHint = nil
|
|
return
|
|
}
|
|
if animated, !reduceMotion {
|
|
withAnimation(Motion.soft) { hintOpacity = 0 }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
currentHint = next
|
|
withAnimation(Motion.soft) { hintOpacity = 1 }
|
|
}
|
|
} else {
|
|
currentHint = next
|
|
hintOpacity = 1
|
|
}
|
|
}
|
|
}
|