feat: add audio input and stabilize gemma 4 inference

This commit is contained in:
Kellyvv committed 2026-04-07 01:50:29 +08:00
1 parent b6d3e54fc3
commit 0d223ae373
22 files changed
+3622 -217

No files matched your search

+50 -3
View File
@@ -14,6 +14,7 @@ struct SkillCard: Identifiable, Equatable {
struct ResponseBlock: Identifiable, Equatable {
let id: UUID
var skills: [SkillCard] = []
var thinkingText: String?
var responseText: String?
var isThinking: Bool
}
@@ -35,6 +36,41 @@ enum DisplayItem: Identifiable {
/// 用于纯思考占位的稳定 ID
private let thinkingPlaceholderID = UUID()
private let thinkingOpenMarker = "[[PHONECLAW_THINK]]"
private let thinkingCloseMarker = "[[/PHONECLAW_THINK]]"
private func splitThinkingAndResponse(from text: String) -> (thinking: String?, response: String?) {
guard !text.isEmpty else { return (nil, nil) }
var remaining = text
var thinkingChunks: [String] = []
while let openRange = remaining.range(of: thinkingOpenMarker) {
let thoughtStart = openRange.upperBound
if let closeRange = remaining.range(of: thinkingCloseMarker, range: thoughtStart..<remaining.endIndex) {
let thought = remaining[thoughtStart..<closeRange.lowerBound]
.trimmingCharacters(in: .whitespacesAndNewlines)
if !thought.isEmpty {
thinkingChunks.append(String(thought))
}
remaining.removeSubrange(openRange.lowerBound..<closeRange.upperBound)
} else {
let thought = remaining[thoughtStart..<remaining.endIndex]
.trimmingCharacters(in: .whitespacesAndNewlines)
if !thought.isEmpty {
thinkingChunks.append(String(thought))
}
remaining.removeSubrange(openRange.lowerBound..<remaining.endIndex)
break
}
}
let response = remaining.trimmingCharacters(in: .whitespacesAndNewlines)
return (
thinkingChunks.isEmpty ? nil : thinkingChunks.joined(separator: "\n\n"),
response.isEmpty ? nil : response
)
}
func buildDisplayItems(from messages: [ChatMessage], isProcessing: Bool) -> [DisplayItem] {
var items: [DisplayItem] = []
@@ -72,18 +108,29 @@ func buildDisplayItems(from messages: [ChatMessage], isProcessing: Bool) -> [Dis
}
block?.skills.append(card)
}
} else if !msg.content.isEmpty {
if block == nil { block = ResponseBlock(id: msg.id, isThinking: false) }
let parsed = splitThinkingAndResponse(from: msg.content)
if let thinking = parsed.thinking {
block?.thinkingText = thinking
}
block?.responseText = parsed.response
}
case .skillResult:
break
case .assistant:
if block == nil { block = ResponseBlock(id: msg.id, isThinking: false) }
if msg.content != "▍" && !msg.content.isEmpty {
block?.responseText = msg.content
let parsed = splitThinkingAndResponse(from: msg.content)
if let thinking = parsed.thinking {
block?.thinkingText = thinking
}
block?.responseText = parsed.response
}
}
}
if var b = block, !b.skills.isEmpty || b.responseText != nil {
if var b = block, !b.skills.isEmpty || b.thinkingText != nil || b.responseText != nil {
if isProcessing && !b.skills.isEmpty && b.responseText == nil {
b.isThinking = true
}
@@ -91,7 +138,7 @@ func buildDisplayItems(from messages: [ChatMessage], isProcessing: Bool) -> [Dis
}
if isProcessing {
let hasAI = block.map { !$0.skills.isEmpty || $0.responseText != nil } ?? false
let hasAI = block.map { !$0.skills.isEmpty || $0.thinkingText != nil || $0.responseText != nil } ?? false
if !hasAI {
items.append(.response(ResponseBlock(id: thinkingPlaceholderID, isThinking: true)))
}
+4
View File
@@ -457,6 +457,8 @@ struct ConfigurationsView: View {
private func permissionTitle(_ kind: AppPermissionKind) -> String {
switch kind {
case .microphone:
return localized("麦克风", "Microphone")
case .calendar:
return localized("日历", "Calendar")
case .reminders:
@@ -468,6 +470,8 @@ struct ConfigurationsView: View {
private func permissionDescription(_ kind: AppPermissionKind) -> String {
switch kind {
case .microphone:
return localized("允许录音并采集实时音频输入", "Allow recording and capturing realtime audio input")
case .calendar:
return localized("允许创建和写入日历事项", "Allow creating and writing calendar events")
case .reminders:
+793 -46
View File
@@ -1,4 +1,7 @@
import SwiftUI
#if canImport(AVFoundation)
import AVFoundation
#endif
#if canImport(PhotosUI)
import PhotosUI
#endif
@@ -6,11 +9,16 @@ import PhotosUI
import UIKit
#endif
private func localizedThinkingText(_ zh: String, _ en: String) -> String {
Locale.preferredLanguages.contains { $0.hasPrefix("zh") } ? zh : en
}
// MARK: - 主入口
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var engine = AgentEngine()
@State private var audioCapture = AudioCaptureService()
@State private var inputText = ""
@State private var selectedImages: [UIImage] = []
@State private var selectedPhotoItem: PhotosPickerItem?
@@ -18,12 +26,49 @@ struct ContentView: View {
@State private var showSkillsManager = false
/// 记录每个 skill 卡片的展开状态(key = SkillCard.id)
@State private var expandedSkills: Set<UUID> = []
/// 记录每个 THINK 卡片的展开状态(key = ResponseBlock.id)
@State private var expandedThoughts: Set<UUID> = []
@FocusState private var isInputFocused: Bool
private var displayItems: [DisplayItem] {
buildDisplayItems(from: engine.messages, isProcessing: engine.isProcessing)
}
private var scrollAnchorState: String {
guard let last = displayItems.last else {
return "empty:\(engine.isProcessing)"
}
switch last {
case .user(let msg):
return [
"user",
msg.id.uuidString,
String(msg.content.count),
String(msg.images.count),
String(msg.audios.count)
].joined(separator: ":")
case .response(let block):
let skillSignature = block.skills.map {
[
$0.id.uuidString,
$0.skillName,
$0.skillStatus ?? "",
$0.toolName ?? ""
].joined(separator: "|")
}.joined(separator: "||")
return [
"response",
block.id.uuidString,
block.thinkingText ?? "",
block.responseText ?? "",
block.isThinking ? "1" : "0",
skillSignature
].joined(separator: ":")
}
}
var body: some View {
ZStack {
Theme.bg.ignoresSafeArea()
@@ -37,6 +82,8 @@ struct ContentView: View {
chatList
}
composerAttachmentsPanel
if engine.messages.isEmpty {
skillChips.padding(.bottom, 8)
}
@@ -50,8 +97,12 @@ struct ContentView: View {
await loadSelectedPhoto()
}
.onChange(of: scenePhase) { _, newPhase in
guard newPhase != .active else { return }
if newPhase == .active {
audioCapture.refreshPermissionStatus()
return
}
engine.cancelActiveGeneration()
_ = audioCapture.stopCapture()
}
.sheet(isPresented: $showConfigurations) {
ConfigurationsView(engine: engine)
@@ -70,12 +121,18 @@ struct ContentView: View {
ForEach(displayItems) { item in
switch item {
case .user(let msg):
UserBubble(text: msg.content, images: msg.images.compactMap(\.uiImage))
UserBubble(
text: msg.content,
images: msg.images.compactMap(\.uiImage),
audios: msg.audios
)
case .response(let block):
AIResponseView(
block: block,
expandedSkills: expandedSkills,
onToggle: { toggleExpand($0) }
isThinkingExpanded: expandedThoughts.contains(block.id),
onToggle: { toggleExpand($0) },
onToggleThinking: { toggleThinking(block.id) }
)
}
}
@@ -84,15 +141,24 @@ struct ContentView: View {
.padding(.vertical, 20)
}
.scrollIndicators(.hidden)
.onAppear { scrollTo(proxy, animated: false) }
.onChange(of: engine.messages.count) { scrollTo(proxy) }
.onChange(of: engine.isProcessing) { scrollTo(proxy) }
.onChange(of: scrollAnchorState) { scrollTo(proxy) }
}
}
private func scrollTo(_ proxy: ScrollViewProxy) {
private func scrollTo(_ proxy: ScrollViewProxy, animated: Bool = true) {
guard let last = displayItems.last else { return }
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo(last.id, anchor: .bottom)
let lastID = last.id
DispatchQueue.main.async {
if animated {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(lastID, anchor: .bottom)
}
} else {
proxy.scrollTo(lastID, anchor: .bottom)
}
}
}
@@ -106,6 +172,21 @@ struct ContentView: View {
}
}
private func toggleThinking(_ id: UUID) {
withAnimation(.easeInOut(duration: 0.25)) {
if expandedThoughts.contains(id) {
expandedThoughts.remove(id)
} else {
expandedThoughts.insert(id)
}
}
}
private func toggleThinkingMode() {
engine.config.enableThinking.toggle()
engine.applySamplingConfig()
}
// MARK: - 顶部栏
private var topBar: some View {
@@ -136,6 +217,23 @@ struct ContentView: View {
// 右:Skills + 设置
HStack(spacing: 6) {
Button(action: toggleThinkingMode) {
HStack(spacing: 6) {
Image(systemName: engine.config.enableThinking ? "sparkles" : "sparkles")
.font(.system(size: 11, weight: .semibold))
Text(localizedThinkingText("思考", "Think"))
.font(.system(size: 11, weight: .semibold, design: .rounded))
}
.foregroundStyle(engine.config.enableThinking ? Theme.bg : Theme.textSecondary)
.padding(.horizontal, 10)
.frame(height: 34)
.background(
engine.config.enableThinking ? Theme.accent : Theme.bgElevated,
in: RoundedRectangle(cornerRadius: 9)
)
}
.buttonStyle(.plain)
Button(action: { showSkillsManager = true }) {
Image(systemName: "puzzlepiece.extension")
.font(.system(size: 14, weight: .medium))
@@ -215,36 +313,6 @@ struct ContentView: View {
private var inputBar: some View {
VStack(spacing: 10) {
if !selectedImages.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(selectedImages.enumerated()), id: \.offset) { index, image in
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Theme.border, lineWidth: 1)
)
Button {
selectedImages.remove(at: index)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundStyle(.white, Color.black.opacity(0.65))
}
.offset(x: 6, y: -6)
}
}
}
.padding(.horizontal, Theme.inputPadH)
}
}
HStack(spacing: 10) {
#if canImport(PhotosUI)
PhotosPicker(selection: $selectedPhotoItem, matching: .images) {
@@ -257,6 +325,22 @@ struct ContentView: View {
.buttonStyle(.plain)
#endif
Button {
Task {
_ = await audioCapture.toggleCapture()
}
} label: {
Image(systemName: audioCapture.isCapturing ? "stop.fill" : "mic.fill")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(audioCapture.isCapturing ? Theme.bg : Theme.textSecondary)
.frame(width: 34, height: 34)
.background(
audioCapture.isCapturing ? Theme.accent : Theme.bgElevated,
in: RoundedRectangle(cornerRadius: 9)
)
}
.buttonStyle(.plain)
#if os(macOS)
TextField("Message…", text: $inputText)
.textFieldStyle(.plain)
@@ -298,19 +382,106 @@ struct ContentView: View {
}
}
@ViewBuilder
private var composerAttachmentsPanel: some View {
if audioCapture.isCapturing
|| audioCapture.latestSnapshot() != nil
|| audioCapture.lastErrorMessage != nil
|| !selectedImages.isEmpty {
VStack(spacing: 10) {
audioComposerPanel
if !selectedImages.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(selectedImages.enumerated()), id: \.offset) { index, image in
ZStack(alignment: .topTrailing) {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Theme.border, lineWidth: 1)
)
Button {
selectedImages.remove(at: index)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundStyle(.white, Color.black.opacity(0.65))
}
.offset(x: 6, y: -6)
}
}
}
.padding(.horizontal, Theme.inputPadH)
}
}
}
.padding(.bottom, engine.messages.isEmpty ? 8 : 0)
}
}
@ViewBuilder
private var audioComposerPanel: some View {
if audioCapture.isCapturing {
RecordingStatusCard(
duration: audioCapture.duration,
sampleRate: max(audioCapture.sampleRate, 16_000),
peakLevel: audioCapture.peakLevel,
onStop: {
_ = audioCapture.stopCapture()
},
onDiscard: {
_ = audioCapture.stopCapture()
_ = audioCapture.consumeLatestSnapshot()
}
)
.padding(.horizontal, Theme.inputPadH)
} else if let draft = audioCapture.latestSnapshot(),
let attachment = ChatAudioAttachment(snapshot: draft) {
ComposerAudioDraftCard(
attachment: attachment,
onDiscard: {
_ = audioCapture.consumeLatestSnapshot()
}
)
.padding(.horizontal, Theme.inputPadH)
} else if let error = audioCapture.lastErrorMessage {
AudioErrorBanner(
message: error,
onDismiss: {
audioCapture.clearStatus()
}
)
.padding(.horizontal, Theme.inputPadH)
}
}
private var canSend: Bool {
(!inputText.trimmingCharacters(in: .whitespaces).isEmpty || !selectedImages.isEmpty)
(
!inputText.trimmingCharacters(in: .whitespaces).isEmpty
|| !selectedImages.isEmpty
|| audioCapture.bufferedSampleCount > 0
)
&& !engine.isProcessing && engine.llm.isLoaded
}
private func send() async {
let text = inputText
let images = selectedImages
if audioCapture.isCapturing {
_ = audioCapture.stopCapture()
}
let audioSnapshot = audioCapture.consumeLatestSnapshot()
inputText = ""
selectedImages = []
selectedPhotoItem = nil
isInputFocused = false
await engine.processInput(text, images: images)
await engine.processInput(text, images: images, audio: audioSnapshot)
}
@MainActor
@@ -320,7 +491,7 @@ struct ContentView: View {
do {
if let data = try await selectedPhotoItem.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
selectedImages = [image]
selectedImages = [ChatImageAttachment.preparedImage(image)]
}
} catch {
print("[UI] Failed to load selected photo: \(error)")
@@ -334,10 +505,14 @@ struct ContentView: View {
struct UserBubble: View {
let text: String
let images: [UIImage]
let audios: [ChatAudioAttachment]
var body: some View {
HStack {
Spacer(minLength: Theme.bubbleMinSpacer)
VStack(alignment: .trailing, spacing: 8) {
ForEach(audios) { audio in
AudioAttachmentBubble(attachment: audio)
}
ForEach(Array(images.enumerated()), id: \.offset) { _, image in
Image(uiImage: image)
.resizable()
@@ -345,17 +520,497 @@ struct UserBubble: View {
.frame(width: 180, height: 180)
.clipShape(RoundedRectangle(cornerRadius: 18))
}
Text(text)
.font(.system(size: 15))
.foregroundStyle(Theme.userText)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Theme.userBubble, in: UserBubbleShape())
if !text.isEmpty {
Text(text)
.font(.system(size: 15))
.foregroundStyle(Theme.userText)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Theme.userBubble, in: UserBubbleShape())
}
}
}
}
}
struct AudioAttachmentBubble: View {
let attachment: ChatAudioAttachment
@StateObject private var player = AudioAttachmentPlayer()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Label("语音消息", systemImage: "waveform.badge.mic")
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.textPrimary)
Spacer(minLength: 8)
Text(attachment.formattedDuration)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundStyle(Theme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.bg.opacity(0.35), in: Capsule())
}
HStack(spacing: 12) {
AudioPlaybackActionButton(
isPlaying: player.isPlaying,
action: { player.togglePlayback(data: attachment.wavData) }
)
VStack(alignment: .leading, spacing: 6) {
AudioWaveformView(
levels: attachment.waveform,
progress: player.progress,
isPlaying: player.isPlaying,
activeColor: Theme.accent,
inactiveColor: Theme.textTertiary.opacity(0.45),
barWidth: 4,
minHeight: 8,
maxExtraHeight: 18
)
.frame(height: 30)
HStack(spacing: 8) {
Text(player.isPlaying ? "播放中" : "点击播放")
.font(.system(size: 11))
.foregroundStyle(player.isPlaying ? Theme.accent : Theme.textSecondary)
Spacer(minLength: 8)
Text(player.secondaryStatusText(totalDuration: attachment.duration))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(Theme.textTertiary)
}
}
}
}
.frame(minWidth: 230, maxWidth: 272, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
LinearGradient(
colors: [Theme.bgElevated, Theme.bgHover.opacity(0.94)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 20, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Theme.borderSubtle, lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.12), radius: 10, y: 4)
}
}
struct AudioWaveformView: View {
let levels: [Float]
var progress: Double = 0
var isPlaying: Bool = false
var activeColor: Color = Theme.accent
var inactiveColor: Color = Theme.textTertiary
var barWidth: CGFloat = 3
var minHeight: CGFloat = 6
var maxExtraHeight: CGFloat = 18
var body: some View {
HStack(alignment: .center, spacing: 2) {
ForEach(Array(levels.enumerated()), id: \.offset) { index, level in
let threshold = Double(index + 1) / Double(max(levels.count, 1))
let isActive = progress > 0 ? threshold <= progress : isPlaying
Capsule()
.fill(isActive ? activeColor : inactiveColor)
.frame(
width: barWidth,
height: minHeight + CGFloat(level) * maxExtraHeight
)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.animation(.easeInOut(duration: 0.18), value: progress)
.animation(.easeInOut(duration: 0.18), value: isPlaying)
}
}
@MainActor
final class AudioAttachmentPlayer: NSObject, ObservableObject, @preconcurrency AVAudioPlayerDelegate {
@Published private(set) var isPlaying = false
@Published private(set) var progress: Double = 0
@Published private(set) var currentTime: TimeInterval = 0
private var player: AVAudioPlayer?
private var timer: Timer?
private var loadedClipSignature: Int?
private let audioSession = AVAudioSession.sharedInstance()
func togglePlayback(data: Data) {
if isPlaying {
pause()
} else if loadedClipSignature == data.hashValue, player != nil {
resume()
} else {
play(data: data)
}
}
func stop() {
invalidateTimer()
player?.stop()
player = nil
loadedClipSignature = nil
isPlaying = false
progress = 0
currentTime = 0
try? audioSession.setActive(false, options: [.notifyOthersOnDeactivation])
}
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
invalidateTimer()
self.player = nil
loadedClipSignature = nil
isPlaying = false
progress = 0
currentTime = 0
try? audioSession.setActive(false, options: [.notifyOthersOnDeactivation])
}
func secondaryStatusText(totalDuration: TimeInterval) -> String {
guard totalDuration > 0 else { return "--:--" }
if isPlaying || currentTime > 0 {
return "\(formatTime(currentTime)) / \(formatTime(totalDuration))"
}
return formatTime(totalDuration)
}
private func pause() {
player?.pause()
invalidateTimer()
isPlaying = false
syncProgress()
}
private func resume() {
guard let player else { return }
guard player.play() else { return }
isPlaying = true
startProgressUpdates()
}
private func invalidateTimer() {
timer?.invalidate()
timer = nil
}
private func startProgressUpdates() {
invalidateTimer()
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.syncProgress()
}
}
RunLoop.main.add(timer!, forMode: .common)
}
private func syncProgress() {
guard let player else {
progress = 0
currentTime = 0
return
}
currentTime = player.currentTime
progress = player.duration > 0 ? player.currentTime / player.duration : 0
}
private func formatTime(_ time: TimeInterval) -> String {
let totalSeconds = max(Int(time.rounded()), 0)
return String(format: "%02d:%02d", totalSeconds / 60, totalSeconds % 60)
}
private func play(data: Data) {
stop()
do {
try audioSession.setCategory(.playback, mode: .default, options: [])
try audioSession.setActive(true)
let player = try AVAudioPlayer(data: data)
player.delegate = self
player.prepareToPlay()
if !player.play() {
print("[AudioUI] playback failed: AVAudioPlayer returned false")
isPlaying = false
return
}
self.player = player
loadedClipSignature = data.hashValue
isPlaying = true
progress = 0
currentTime = 0
startProgressUpdates()
} catch {
print("[AudioUI] playback failed: \(error.localizedDescription)")
isPlaying = false
}
}
}
struct AudioPlaybackActionButton: View {
let isPlaying: Bool
var symbolName: String? = nil
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: symbolName ?? (isPlaying ? "pause.fill" : "play.fill"))
.font(.system(size: 15, weight: .bold))
.foregroundStyle(Theme.bg)
.frame(width: 38, height: 38)
.background(
LinearGradient(
colors: [Theme.accent, Theme.accent.opacity(0.82)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: Circle()
)
.shadow(color: Theme.accent.opacity(0.22), radius: 10, y: 4)
}
.buttonStyle(.plain)
}
}
struct RecordingStatusCard: View {
let duration: TimeInterval
let sampleRate: Double
let peakLevel: Float
let onStop: () -> Void
let onDiscard: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
HStack(spacing: 8) {
Circle()
.fill(Color.red.opacity(0.92))
.frame(width: 8, height: 8)
Text("录音中")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.textPrimary)
}
Spacer(minLength: 8)
audioMetaChip(text: formattedDuration, emphasized: true)
audioMetaChip(text: sampleRateText)
}
HStack(spacing: 12) {
AudioPlaybackActionButton(
isPlaying: false,
symbolName: "stop.fill",
action: onStop
)
.overlay(
Circle()
.strokeBorder(Color.red.opacity(0.25), lineWidth: 8)
.scaleEffect(1.08)
)
VStack(alignment: .leading, spacing: 8) {
RecordingLevelBars(level: peakLevel)
.frame(height: 28)
Text("点左侧按钮结束录音,发送时会作为音频附件一并发出。")
.font(.system(size: 11))
.foregroundStyle(Theme.textSecondary)
.lineLimit(2)
}
Button(action: onDiscard) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Theme.textSecondary)
.frame(width: 30, height: 30)
.background(Theme.bg.opacity(0.42), in: Circle())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.background(
LinearGradient(
colors: [Theme.bgElevated, Theme.bgHover.opacity(0.98)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 22, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Theme.border, lineWidth: 1)
)
}
private var formattedDuration: String {
let totalSeconds = max(Int(duration.rounded()), 0)
return String(format: "%02d:%02d", totalSeconds / 60, totalSeconds % 60)
}
private var sampleRateText: String {
String(format: "%.0f kHz", max(sampleRate, 16_000) / 1000)
}
}
struct ComposerAudioDraftCard: View {
let attachment: ChatAudioAttachment
let onDiscard: () -> Void
@StateObject private var player = AudioAttachmentPlayer()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Label("语音草稿", systemImage: "paperplane.circle.fill")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.textPrimary)
Spacer(minLength: 8)
audioMetaChip(text: attachment.formattedDuration, emphasized: true)
Button(action: onDiscard) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Theme.textSecondary)
.frame(width: 30, height: 30)
.background(Theme.bg.opacity(0.4), in: Circle())
}
.buttonStyle(.plain)
}
HStack(spacing: 12) {
AudioPlaybackActionButton(
isPlaying: player.isPlaying,
action: { player.togglePlayback(data: attachment.wavData) }
)
VStack(alignment: .leading, spacing: 8) {
AudioWaveformView(
levels: attachment.waveform,
progress: player.progress,
isPlaying: player.isPlaying,
activeColor: Theme.accent,
inactiveColor: Theme.textTertiary.opacity(0.45),
barWidth: 4,
minHeight: 8,
maxExtraHeight: 18
)
.frame(height: 30)
HStack(spacing: 8) {
Text(player.isPlaying ? "预览播放中" : "可直接发送,也可以先试听")
.font(.system(size: 11))
.foregroundStyle(player.isPlaying ? Theme.accent : Theme.textSecondary)
Spacer(minLength: 8)
audioMetaChip(text: String(format: "%.0f kHz", attachment.sampleRate / 1000))
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.background(
LinearGradient(
colors: [Theme.accentSubtle, Theme.bgElevated],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 22, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Theme.accent.opacity(0.2), lineWidth: 1)
)
}
}
struct AudioErrorBanner: View {
let message: String
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 14))
.foregroundStyle(Color.orange)
Text(message)
.font(.system(size: 12))
.foregroundStyle(Theme.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.textSecondary)
.frame(width: 26, height: 26)
.background(Theme.bg.opacity(0.4), in: Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.orange.opacity(0.12))
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.orange.opacity(0.25), lineWidth: 1)
)
}
}
struct RecordingLevelBars: View {
let level: Float
var body: some View {
HStack(alignment: .center, spacing: 3) {
ForEach(0..<24, id: \.self) { index in
let seed = abs(sin(Double(index) * 0.55))
let intensity = max(CGFloat(level), 0.08)
Capsule()
.fill(index < highlightedBarCount ? Theme.accent : Theme.textTertiary.opacity(0.35))
.frame(width: 4, height: 8 + CGFloat(seed) * (8 + intensity * 18))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.animation(.easeInOut(duration: 0.16), value: highlightedBarCount)
}
private var highlightedBarCount: Int {
let normalized = min(max(level, 0), 1)
return max(2, Int((normalized * 24).rounded(.up)))
}
}
@ViewBuilder
private func audioMetaChip(text: String, emphasized: Bool = false) -> some View {
Text(text)
.font(.system(size: 11, weight: emphasized ? .semibold : .medium, design: .monospaced))
.foregroundStyle(emphasized ? Theme.textPrimary : Theme.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
emphasized ? Theme.bg.opacity(0.42) : Theme.bg.opacity(0.3),
in: Capsule()
)
}
struct UserBubbleShape: Shape {
func path(in rect: CGRect) -> Path {
let r: CGFloat = 18, sr: CGFloat = 4
@@ -382,10 +1037,18 @@ struct UserBubbleShape: Shape {
struct AIResponseView: View {
let block: ResponseBlock
let expandedSkills: Set<UUID>
let isThinkingExpanded: Bool
let onToggle: (UUID) -> Void
let onToggleThinking: () -> Void
private var hasSkill: Bool { !block.skills.isEmpty }
private var isPureThinking: Bool { !hasSkill && block.responseText == nil && block.isThinking }
private var hasThinkingText: Bool {
guard let thinking = block.thinkingText else { return false }
return !thinking.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var isPureThinking: Bool {
!hasSkill && !hasThinkingText && block.responseText == nil && block.isThinking
}
var body: some View {
HStack {
@@ -407,6 +1070,15 @@ struct AIResponseView: View {
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
if let thinking = block.thinkingText, !thinking.isEmpty {
ThinkingCardView(
text: thinking,
isExpanded: isThinkingExpanded,
onToggle: onToggleThinking
)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
}
// Skill 完成后等待 follow-up 文字
if hasSkill && block.isThinking && block.responseText == nil {
ThinkingIndicator()
@@ -433,6 +1105,81 @@ struct AIResponseView: View {
}
}
struct ThinkingCardView: View {
let text: String
let isExpanded: Bool
let onToggle: () -> Void
private var lineCount: Int {
max(1, text.components(separatedBy: .newlines).count)
}
private var previewText: String {
let compact = text
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !compact.isEmpty else { return localizedThinkingText("已捕获思考内容", "Captured thinking content") }
return String(compact.prefix(72)) + (compact.count > 72 ? "…" : "")
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
Image(systemName: "brain.head.profile")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 26, height: 26)
.background(Theme.accentSubtle, in: RoundedRectangle(cornerRadius: 7))
VStack(alignment: .leading, spacing: 2) {
Text(localizedThinkingText("思考", "Think"))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.textPrimary)
if !isExpanded {
Text(previewText)
.font(.system(size: 11))
.foregroundStyle(Theme.textTertiary)
.lineLimit(1)
}
}
Spacer()
Text(localizedThinkingText("\(lineCount) 行", "\(lineCount) lines"))
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundStyle(Theme.textTertiary)
Image(systemName: "chevron.down")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Theme.textTertiary)
.rotationEffect(.degrees(isExpanded ? -180 : 0))
.animation(.easeInOut(duration: 0.25), value: isExpanded)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.contentShape(Rectangle())
.onTapGesture { onToggle() }
if isExpanded {
Rectangle().fill(Theme.borderSubtle).frame(height: 1)
Text(text)
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)
.textSelection(.enabled)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
}
.background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Theme.border, lineWidth: 1))
}
}
// MARK: - 单个 Skill 卡片(4 步进度)
struct SkillCardView: View {