feat(keyboard): ship AI mode surface with streaming search answers
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
// AIKeyboardView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Temporary voice-to-AI surface. The latest answer remains visible while a
|
||||
// follow-up is running 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
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
let onInsert: (String) -> Void
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
if state.canCancelAIInput {
|
||||
KeyboardCancelButton(
|
||||
action: state.cancelAIInput,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.ai.cancel"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.ai.cancelHint")
|
||||
)
|
||||
} else {
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
private var answerArea: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
if showsPlaceholder {
|
||||
// Empty-state tip: geometric center of the answer plane.
|
||||
Text(ExtL10n.string("keyboard.ai.placeholder"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
/// No draft/answer yet — show the centered mic guidance 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 {
|
||||
Capsule().fill(palette.accent)
|
||||
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
|
||||
)
|
||||
.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()
|
||||
)
|
||||
.overlay(Capsule().stroke(answerActionBorder, lineWidth: 0.5))
|
||||
.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
|
||||
}
|
||||
return state.aiSession.canSend
|
||||
? palette.accent
|
||||
: NativeKeyboardKeyColors.fill(for: colorScheme)
|
||||
}
|
||||
|
||||
private var answerActionForeground: Color {
|
||||
guard state.aiSession.canPerformAnswerAction else {
|
||||
return palette.textTertiary
|
||||
}
|
||||
return state.aiSession.canSend
|
||||
? .white
|
||||
: NativeKeyboardKeyColors.text(for: colorScheme)
|
||||
}
|
||||
|
||||
private var answerActionBorder: Color {
|
||||
guard state.aiSession.canSend else {
|
||||
return palette.divider
|
||||
}
|
||||
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
|
||||
}
|
||||
|
||||
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:
|
||||
return state.aiSession.transcript.isEmpty
|
||||
? ExtL10n.string("keyboard.ai.recognizing")
|
||||
: 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
|
||||
case .failed:
|
||||
return ExtL10n.string("keyboard.ai.error.requestFailed")
|
||||
}
|
||||
}
|
||||
|
||||
private var microphoneAccessibilityKey: String {
|
||||
state.aiSession.phase == .listening
|
||||
? "keyboard.ai.stopA11y"
|
||||
: "keyboard.ai.startA11y"
|
||||
}
|
||||
}
|
||||
@@ -613,9 +613,16 @@ private struct TranscriptLine: View {
|
||||
Group {
|
||||
switch micVoiceAvailability {
|
||||
case .ready:
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
// Soft tip when polish key is missing but local ASR can still run.
|
||||
if !micDisabledHint.isEmpty {
|
||||
Text(micDisabledHint)
|
||||
} else {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
}
|
||||
case .unavailable(.missingAPIKey):
|
||||
Text(micDisabledHint)
|
||||
Text(micDisabledHint.isEmpty
|
||||
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
||||
: micDisabledHint)
|
||||
case .unavailable(.hostNotReady):
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
case .unavailable(.preparingSession):
|
||||
@@ -631,7 +638,11 @@ private struct TranscriptLine: View {
|
||||
}
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(isWarning ? palette.warning : palette.textTertiary)
|
||||
.foregroundStyle(
|
||||
(isWarning || (micVoiceAvailability.isReady && !micDisabledHint.isEmpty))
|
||||
? palette.warning
|
||||
: palette.textTertiary
|
||||
)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
@@ -73,12 +73,14 @@ struct KeyboardCancelButton: View {
|
||||
}
|
||||
|
||||
private enum KeyboardInputTab: CaseIterable {
|
||||
case ai
|
||||
case voice
|
||||
case chinese
|
||||
case english
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .ai: return "AI"
|
||||
case .voice: return "语音"
|
||||
case .chinese: return "中文"
|
||||
case .english: return "EN"
|
||||
@@ -107,7 +109,10 @@ struct KeyboardTopControls: View {
|
||||
.foregroundStyle(
|
||||
isSelected(tab) ? palette.textPrimary : palette.textSecondary
|
||||
)
|
||||
.frame(width: tab == .english ? 34 : 42, height: 30)
|
||||
.frame(
|
||||
width: tab == .english || tab == .ai ? 34 : 42,
|
||||
height: 30
|
||||
)
|
||||
.background {
|
||||
if isSelected(tab) {
|
||||
Capsule()
|
||||
@@ -163,6 +168,8 @@ struct KeyboardTopControls: View {
|
||||
|
||||
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
||||
switch tab {
|
||||
case .ai:
|
||||
return state.surface == .ai
|
||||
case .voice:
|
||||
return state.surface == .voice
|
||||
case .chinese:
|
||||
@@ -174,6 +181,8 @@ struct KeyboardTopControls: View {
|
||||
|
||||
private func select(_ tab: KeyboardInputTab) {
|
||||
switch tab {
|
||||
case .ai:
|
||||
state.setSurface(.ai)
|
||||
case .voice:
|
||||
state.setSurface(.voice)
|
||||
case .chinese:
|
||||
@@ -202,6 +211,7 @@ struct KeyboardTopControls: View {
|
||||
|
||||
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
|
||||
switch tab {
|
||||
case .ai: return "切换到 AI 问答"
|
||||
case .voice: return "切换到语音输入"
|
||||
case .chinese: return "切换到中文输入"
|
||||
case .english: return "切换到英文输入"
|
||||
|
||||
Reference in New Issue
Block a user