feat: add audio input and stabilize gemma 4 inference
This commit is contained in:
1 parent
b6d3e54fc3
commit
0d223ae373
22 files changed
+3622
-217
No files matched your search
+50
-3
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user