import SwiftUI #if canImport(AVFoundation) import AVFoundation #endif #if canImport(PhotosUI) import PhotosUI #endif #if canImport(UIKit) 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? @State private var showConfigurations = false @State private var showSkillsManager = false @State private var showHistory = false /// 记录每个 skill 卡片的展开状态(key = SkillCard.id) @State private var expandedSkills: Set = [] /// 记录每个 THINK 卡片的展开状态(key = ResponseBlock.id) @State private var expandedThoughts: Set = [] @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() VStack(spacing: 0) { topBar if engine.messages.isEmpty { welcomeView } else { chatList } composerAttachmentsPanel if engine.messages.isEmpty { skillChips.padding(.bottom, 8) } inputBar } } .preferredColorScheme(.dark) .task { engine.setup() } .task(id: selectedPhotoItem) { await loadSelectedPhoto() } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { audioCapture.refreshPermissionStatus() return } engine.flushPendingSessionSave() engine.cancelActiveGeneration() _ = audioCapture.stopCapture() } .sheet(isPresented: $showHistory) { SessionHistorySheet(engine: engine) } .sheet(isPresented: $showConfigurations) { ConfigurationsView(engine: engine) } .sheet(isPresented: $showSkillsManager) { SkillsManagerView(engine: engine) } } // MARK: - 聊天列表 private var chatList: some View { ScrollViewReader { proxy in ScrollView { VStack(spacing: Theme.chatSpacing) { ForEach(displayItems) { item in switch item { case .user(let msg): UserBubble( text: msg.content, images: msg.images.compactMap(\.uiImage), audios: msg.audios ) case .response(let block): AIResponseView( block: block, expandedSkills: expandedSkills, isThinkingExpanded: expandedThoughts.contains(block.id), onToggle: { toggleExpand($0) }, onToggleThinking: { toggleThinking(block.id) } ) } } } .padding(.horizontal, Theme.chatPadH) .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, animated: Bool = true) { guard let last = displayItems.last else { return } 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) } } } private func toggleExpand(_ id: UUID) { withAnimation(.easeInOut(duration: 0.25)) { if expandedSkills.contains(id) { expandedSkills.remove(id) } else { expandedSkills.insert(id) } } } 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 { HStack(spacing: 0) { // 左:历史/新会话 Button(action: { engine.flushPendingSessionSave() showHistory = true }) { Image(systemName: "clock.arrow.circlepath") .font(.system(size: 15, weight: .medium)) .foregroundStyle(Theme.textSecondary) .frame(width: 34, height: 34) .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 9)) } .buttonStyle(.plain) Spacer() // 中:模型状态 HStack(spacing: 6) { Circle() .fill(engine.llm.isLoaded ? Theme.accentGreen : Theme.accent) .frame(width: 6, height: 6) Text(engine.llm.isLoaded ? engine.llm.modelDisplayName : engine.llm.statusMessage) .font(.system(size: 12, weight: .medium, design: .rounded)) .foregroundStyle(Theme.textSecondary) } Spacer() // 右: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)) .foregroundStyle(Theme.textSecondary) .frame(width: 34, height: 34) .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 9)) } .buttonStyle(.plain) Button(action: { showConfigurations = true }) { Image(systemName: "slider.horizontal.3") .font(.system(size: 15, weight: .medium)) .foregroundStyle(Theme.textSecondary) .frame(width: 34, height: 34) .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 9)) } .buttonStyle(.plain) } } .padding(.horizontal, Theme.inputPadH) .padding(.vertical, 10) } // MARK: - 欢迎页 private var welcomeView: some View { VStack(spacing: 0) { Spacer() ZStack { Circle().fill(Theme.accentSubtle).frame(width: 60, height: 60) Image(systemName: "sparkles") .font(.system(size: 24, weight: .medium)) .foregroundStyle(Theme.accent) } Text("PhoneClaw") .font(.system(size: 26, weight: .semibold, design: .rounded)) .foregroundStyle(Theme.textPrimary) .padding(.top, 16) Text("On-device AI Agent") .font(.system(size: 14)) .foregroundStyle(Theme.textTertiary) .padding(.top, 4) Spacer() } } // MARK: - Skill 快捷标签 /// chip 显示用的简写标签 (UI 表现层选择, 不影响核心数据)。 /// key = SkillEntry.id, value = 在 chip pill 上显示的短动作短语。 /// 没在表里的 skill 会 fallback 到 skill.displayName。 /// chip 实际发送的 prompt 仍然是 skill.samplePrompt (完整、自包含)。 private static let chipShortLabels: [String: String] = [ "calendar": "帮我创建日程", "contacts": "帮我保存联系人", "reminders": "帮我创建提醒事项", "device": "查询我手机信息", ] /// chip 隐藏名单 (UI 表现层选择): 这些 skill 不会出现在欢迎页 chip 列表里, /// 但仍然在引擎里启用、可被用户手动输入触发。 private static let chipHiddenSkillIds: Set = ["text"] private var skillChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(engine.enabledSkillInfos.filter { !Self.chipHiddenSkillIds.contains($0.name) }, id: \.name) { skill in let label = Self.chipShortLabels[skill.name] ?? skill.displayName Button { inputText = skill.samplePrompt Task { await send() } } label: { HStack(spacing: 5) { Image(systemName: skill.icon).font(.system(size: 11)) Text(label).font(.system(size: 12, weight: .medium)) } .foregroundStyle(Theme.textSecondary) .padding(.horizontal, 12) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 20) .strokeBorder(Theme.border, lineWidth: 1) ) } .buttonStyle(.plain) } } .padding(.horizontal, Theme.chatPadH) } } // MARK: - 输入栏 private var inputBar: some View { VStack(spacing: 10) { HStack(spacing: 10) { #if canImport(PhotosUI) PhotosPicker(selection: $selectedPhotoItem, matching: .images) { Image(systemName: "photo") .font(.system(size: 15, weight: .medium)) .foregroundStyle(Theme.textSecondary) .frame(width: 34, height: 34) .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 9)) } .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) .font(.system(size: 15)) .foregroundStyle(Theme.textPrimary) .padding(.horizontal, 16) .padding(.vertical, 11) .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 22)) .overlay(RoundedRectangle(cornerRadius: 22).strokeBorder(Theme.border, lineWidth: 1)) .onSubmit { Task { await send() } } #else TextField("Message…", text: $inputText, axis: .vertical) .lineLimit(1...5) .font(.system(size: 15)) .foregroundStyle(Theme.textPrimary) .padding(.horizontal, 16) .padding(.vertical, 11) .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 22)) .overlay(RoundedRectangle(cornerRadius: 22).strokeBorder(Theme.border, lineWidth: 1)) .focused($isInputFocused) .onSubmit { Task { await send() } } #endif Button { if canCancelGeneration { engine.cancelActiveGeneration() } else { Task { await send() } } } label: { Image(systemName: canCancelGeneration ? "stop.fill" : "arrow.up") .font(.system(size: 14, weight: .bold)) .foregroundStyle( canCancelGeneration || canSend ? Theme.bg : Theme.textTertiary ) .frame(width: 34, height: 34) .background( canCancelGeneration ? Color.red.opacity(0.92) : (canSend ? Theme.accent : Theme.bgElevated), in: Circle() ) .overlay( Circle().strokeBorder( canCancelGeneration || canSend ? .clear : Theme.border, lineWidth: 1 ) ) } .buttonStyle(.plain) .disabled(!canSend && !canCancelGeneration) .animation(.easeInOut(duration: 0.15), value: canSend) .animation(.easeInOut(duration: 0.15), value: canCancelGeneration) } .padding(.horizontal, Theme.inputPadH) .padding(.vertical, 14) .background(Theme.bg) } } @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 || audioCapture.bufferedSampleCount > 0 ) && !engine.isProcessing && engine.llm.isLoaded } private var canCancelGeneration: Bool { engine.isProcessing || engine.llm.isGenerating } 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, audio: audioSnapshot) } @MainActor private func loadSelectedPhoto() async { #if canImport(PhotosUI) guard let selectedPhotoItem else { return } do { if let data = try await selectedPhotoItem.loadTransferable(type: Data.self), let image = UIImage(data: data) { selectedImages = [ChatImageAttachment.preparedImage(image)] } } catch { print("[UI] Failed to load selected photo: \(error)") } #endif } } private struct SessionHistorySheet: View { @Environment(\.dismiss) private var dismiss var engine: AgentEngine private static let dateFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.locale = Locale(identifier: Locale.preferredLanguages.first ?? Locale.current.identifier) formatter.unitsStyle = .short return formatter }() var body: some View { NavigationStack { Group { if engine.sessionSummaries.isEmpty { emptyState } else { List { ForEach(engine.sessionSummaries) { session in Button { engine.loadSession(id: session.id) dismiss() } label: { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Text(session.title) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Theme.textPrimary) .lineLimit(1) if session.id == engine.currentSessionID { Text("当前") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.bg) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Theme.accent, in: Capsule()) } } Text(session.preview) .font(.system(size: 13)) .foregroundStyle(Theme.textSecondary) .lineLimit(2) Text(Self.dateFormatter.localizedString(for: session.updatedAt, relativeTo: Date())) .font(.system(size: 11)) .foregroundStyle(Theme.textTertiary) } Spacer(minLength: 0) } .padding(.vertical, 4) .contentShape(Rectangle()) } .buttonStyle(.plain) .listRowBackground(Theme.bgElevated) } .onDelete { offsets in for index in offsets { engine.deleteSession(id: engine.sessionSummaries[index].id) } } } .scrollContentBackground(.hidden) .background(Theme.bg) } } .navigationTitle("历史记录") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("关闭") { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button { engine.startNewSession() dismiss() } label: { Label("新会话", systemImage: "square.and.pencil") } } } } .preferredColorScheme(.dark) } private var emptyState: some View { VStack(spacing: 14) { Image(systemName: "clock.arrow.circlepath") .font(.system(size: 28, weight: .medium)) .foregroundStyle(Theme.textTertiary) Text("还没有历史记录") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(Theme.textPrimary) Text("开始一次新会话后,聊天内容会自动保存在这里。") .font(.system(size: 14)) .foregroundStyle(Theme.textSecondary) .multilineTextAlignment(.center) Button { engine.startNewSession() dismiss() } label: { Text("开始新会话") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Theme.bg) .padding(.horizontal, 16) .padding(.vertical, 10) .background(Theme.accent, in: Capsule()) } .buttonStyle(.plain) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(24) .background(Theme.bg) } } // MARK: - 用户气泡 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() .scaledToFill() .frame(width: 180, height: 180) .clipShape(RoundedRectangle(cornerRadius: 18)) } if !text.isEmpty { Text(text) .font(.system(size: 15)) .foregroundStyle(Theme.userText) .textSelection(.enabled) .padding(.horizontal, 16) .padding(.vertical, 10) .background(Theme.userBubble, in: UserBubbleShape()) .contextMenu { Button { UIPasteboard.general.string = text } label: { Label("复制", systemImage: "doc.on.doc") } } } } } } } 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 return Path { p in p.move(to: CGPoint(x: rect.minX + r, y: rect.minY)) p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY), tangent2End: CGPoint(x: rect.maxX, y: rect.minY + r), radius: r) p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - sr)) p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), tangent2End: CGPoint(x: rect.maxX - sr, y: rect.maxY), radius: sr) p.addLine(to: CGPoint(x: rect.minX + r, y: rect.maxY)) p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY), tangent2End: CGPoint(x: rect.minX, y: rect.maxY - r), radius: r) p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), tangent2End: CGPoint(x: rect.minX + r, y: rect.minY), radius: r) } } } // MARK: - AI 回复 struct AIResponseView: View { let block: ResponseBlock let expandedSkills: Set let isThinkingExpanded: Bool let onToggle: (UUID) -> Void let onToggleThinking: () -> Void private var hasSkill: Bool { !block.skills.isEmpty } 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 { VStack(alignment: .leading, spacing: 12) { // 纯思考状态(无 skill、无文字) if isPureThinking { ThinkingIndicator() .padding(.leading, 12) .padding(.vertical, 10) } // 所有 Skill 卡片(支持多张) ForEach(block.skills) { card in SkillCardView( card: card, isExpanded: expandedSkills.contains(card.id), onToggle: { onToggle(card.id) } ) .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() .padding(.leading, 12) } // 回复文本 if let text = block.responseText { Text(text) .font(.system(size: 15)) .foregroundStyle(Theme.textPrimary) .textSelection(.enabled) .lineSpacing(5) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) .padding(.leading, 4) .animation(nil, value: text) .contextMenu { Button { UIPasteboard.general.string = text } label: { Label("复制", systemImage: "doc.on.doc") } } } } .animation(.easeInOut(duration: 0.3), value: block.skills.count) Spacer(minLength: Theme.aiMinSpacer) } } } 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 { let card: SkillCard let isExpanded: Bool let onToggle: () -> Void private var isSkillDone: Bool { card.skillStatus == "done" } private var currentStep: Int { switch card.skillStatus { case "identified": return 0 case "loaded": return 1 case let s where s?.hasPrefix("executing") == true: return 2 case "done": return 3 default: return 0 } } var body: some View { VStack(alignment: .leading, spacing: 0) { // 卡片头部 HStack(spacing: 10) { ZStack { Image(systemName: "wrench.and.screwdriver.fill") .font(.system(size: 13)) .foregroundStyle(Theme.accent) .frame(width: 26, height: 26) .background(Theme.accentSubtle, in: RoundedRectangle(cornerRadius: 7)) .opacity(isSkillDone ? 1 : 0) SpinnerIcon() .frame(width: 26, height: 26) .opacity(isSkillDone ? 0 : 1) } .animation(.easeInOut(duration: 0.3), value: isSkillDone) Text(isSkillDone ? "Used \"\(card.skillName)\"" : "Running \"\(card.skillName)\"…") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Theme.textPrimary) .lineLimit(1) .contentTransition(.opacity) .animation(.easeInOut(duration: 0.2), value: isSkillDone) Spacer() 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() } // 展开:4 步进度 if isExpanded { Rectangle().fill(Theme.borderSubtle).frame(height: 1) VStack(alignment: .leading, spacing: 6) { stepRow(label: "识别能力: \(card.skillName)", done: currentStep > 0, active: currentStep == 0) stepRow(label: "加载 Skill 指令", done: currentStep > 1, active: currentStep == 1) stepRow(label: card.toolName != nil ? "执行 \(card.toolName!)" : "执行工具", done: currentStep > 2, active: currentStep == 2) stepRow(label: "生成回复", done: isSkillDone, active: false) } .padding(.horizontal, 12) .padding(.vertical, 10) .animation(.easeInOut(duration: 0.2), value: currentStep) } } .background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Theme.border, lineWidth: 1)) } private func stepRow(label: String, done: Bool, active: Bool = false) -> some View { HStack(spacing: 8) { Group { if done { Image(systemName: "checkmark.circle.fill") .font(.system(size: 11)) .foregroundStyle(Theme.accentGreen) } else if active { ProgressView().controlSize(.mini).tint(Theme.textTertiary) } else { Circle().fill(Theme.textTertiary.opacity(0.3)).frame(width: 6, height: 6) } } .frame(width: 14, height: 14) Text(label) .font(.system(size: 12)) .foregroundStyle(done ? Theme.textSecondary : Theme.textTertiary) } } } // MARK: - 旋转 Spinner struct SpinnerIcon: View { @State private var rotating = false var body: some View { Image(systemName: "asterisk") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.textTertiary) .rotationEffect(.degrees(rotating ? 360 : 0)) .animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: rotating) .onAppear { rotating = true } } } // MARK: - 思考动画 /// 演示 chip 按钮样式: press 时 spring 缩放 + 轻微透明度变化, 让卡片有"活气"。 struct SkillCardButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.96 : 1.0) .opacity(configuration.isPressed ? 0.85 : 1.0) .animation(.spring(response: 0.32, dampingFraction: 0.72), value: configuration.isPressed) } } struct ThinkingIndicator: View { @State private var active = 0 let timer = Timer.publish(every: 0.45, on: .main, in: .common).autoconnect() var body: some View { HStack(spacing: 5) { ForEach(0..<3, id: \.self) { i in Circle() .fill(Theme.textTertiary) .frame(width: 6, height: 6) .opacity(active == i ? 1.0 : 0.3) .scaleEffect(active == i ? 1.0 : 0.75) .animation(.easeInOut(duration: 0.35), value: active) } } .frame(height: 20) .onReceive(timer) { _ in active = (active + 1) % 3 } } }