feat: ship shell app and chat session polish

This commit is contained in:
Kellyvv committed 2026-04-07 11:13:35 +08:00
1 parent 271ac8c1dc
commit 69c4b32248
5 files changed
+532 -40

No files matched your search

+321 -11
View File
@@ -63,17 +63,24 @@ ___SKILLS___
// MARK: - 聊天消息
struct ChatImageAttachment: Identifiable {
let id = UUID()
struct ChatImageAttachment: Identifiable, Codable {
let id: UUID
let data: Data
private static let storageMaxDimension: CGFloat = 1_024
private static let compressionQuality: CGFloat = 0.78
init(id: UUID = UUID(), data: Data) {
self.id = id
self.data = data
}
init?(image: UIImage) {
let prepared = Self.preparedImage(image, maxDimension: Self.storageMaxDimension)
if let jpeg = prepared.jpegData(compressionQuality: Self.compressionQuality) {
self.id = UUID()
self.data = jpeg
} else if let png = prepared.pngData() {
self.id = UUID()
self.data = png
} else {
return nil
@@ -124,15 +131,30 @@ struct ChatImageAttachment: Identifiable {
}
}
struct ChatAudioAttachment: Identifiable {
let id = UUID()
struct ChatAudioAttachment: Identifiable, Codable {
let id: UUID
let wavData: Data
let duration: TimeInterval
let sampleRate: Double
let waveform: [Float]
init(
id: UUID = UUID(),
wavData: Data,
duration: TimeInterval,
sampleRate: Double,
waveform: [Float]
) {
self.id = id
self.wavData = wavData
self.duration = duration
self.sampleRate = sampleRate
self.waveform = waveform
}
init?(snapshot: AudioCaptureSnapshot) {
guard !snapshot.pcm.isEmpty, snapshot.sampleRate > 0 else { return nil }
self.id = UUID()
self.wavData = Self.makeWAVData(
pcm: snapshot.pcm,
sampleRate: snapshot.sampleRate,
@@ -225,27 +247,30 @@ struct ChatAudioAttachment: Identifiable {
}
}
struct ChatMessage: Identifiable {
struct ChatMessage: Identifiable, Codable {
let id: UUID
var role: Role
var content: String
var images: [ChatImageAttachment]
var audios: [ChatAudioAttachment]
let timestamp = Date()
let timestamp: Date
var skillName: String? = nil
init(
id: UUID = UUID(),
role: Role,
content: String,
images: [ChatImageAttachment] = [],
audios: [ChatAudioAttachment] = [],
timestamp: Date = Date(),
skillName: String? = nil
) {
self.id = UUID()
self.id = id
self.role = role
self.content = content
self.images = images
self.audios = audios
self.timestamp = timestamp
self.skillName = skillName
}
@@ -260,20 +285,43 @@ struct ChatMessage: Identifiable {
self.skillName = skillName
}
enum Role {
enum Role: String, Codable {
case user, assistant, system, skillResult
}
}
struct ChatSessionSummary: Identifiable, Codable, Equatable {
let id: UUID
var title: String
var preview: String
var updatedAt: Date
}
private struct ChatSessionRecord: Codable {
let id: UUID
var title: String
var preview: String
var updatedAt: Date
var messages: [ChatMessage]
}
// MARK: - Agent Engine
@Observable
class AgentEngine {
static let currentSessionDefaultsKey = "PhoneClaw.currentSessionID"
let llm = MLXLocalLLMService()
var messages: [ChatMessage] = []
var messages: [ChatMessage] = [] {
didSet {
scheduleSessionSave()
}
}
var isProcessing = false
var config = ModelConfig()
var sessionSummaries: [ChatSessionSummary] = []
var currentSessionID = UUID()
// 文件驱动的 Skill 系统
let skillLoader = SkillLoader()
@@ -284,6 +332,9 @@ class AgentEngine {
private let thinkingOpenMarker = "[[PHONECLAW_THINK]]"
private let thinkingCloseMarker = "[[/PHONECLAW_THINK]]"
private let sessionsDirectoryName = "Sessions"
private let sessionsIndexFileName = "sessions_index.json"
private var sessionSaveTask: Task<Void, Never>?
var enabledSkillInfos: [SkillInfo] {
@@ -299,6 +350,9 @@ class AgentEngine {
init() {
loadSkillEntries()
currentSessionID =
UUID(uuidString: UserDefaults.standard.string(forKey: Self.currentSessionDefaultsKey) ?? "")
?? UUID()
}
private func loadSkillEntries() {
@@ -1725,6 +1779,7 @@ class AgentEngine {
applyModelSelection()
llm.refreshModelInstallStates()
loadSystemPrompt() // 从 SYSPROMPT.md 注入 system prompt
loadPersistedSessions()
applySamplingConfig()
llm.loadModel()
}
@@ -2339,7 +2394,7 @@ class AgentEngine {
// MARK: - 工具
func clearMessages() {
messages.removeAll()
startNewSession()
}
func cancelActiveGeneration() {
@@ -2352,7 +2407,7 @@ class AgentEngine {
messages[lastAssistant].update(content: content.isEmpty ? "(已中断)" : content)
}
log("[Agent] Generation cancelled because the app left foreground")
log("[Agent] Generation cancelled")
}
private func promptImages(
@@ -2363,6 +2418,69 @@ class AgentEngine {
return Array(currentImages.prefix(1).compactMap(\.ciImage))
}
func startNewSession() {
flushPendingSessionSave()
if isProcessing || llm.isGenerating {
cancelActiveGeneration()
}
currentSessionID = UUID()
UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
messages = []
}
func loadSession(id: UUID) {
guard id != currentSessionID || messages.isEmpty else { return }
flushPendingSessionSave()
if isProcessing || llm.isGenerating {
cancelActiveGeneration()
}
guard let record = loadSessionRecord(id: id) else { return }
currentSessionID = id
UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
messages = record.messages
updateSessionSummary(
.init(
id: record.id,
title: record.title,
preview: record.preview,
updatedAt: record.updatedAt
)
)
}
func deleteSession(id: UUID) {
flushPendingSessionSave()
do {
try FileManager.default.removeItem(at: sessionFileURL(for: id))
} catch {
log("[History] delete failed: \(error.localizedDescription)")
}
sessionSummaries.removeAll { $0.id == id }
persistSessionsIndex()
if currentSessionID == id {
sessionSaveTask?.cancel()
sessionSaveTask = nil
if let next = sessionSummaries.first,
let record = loadSessionRecord(id: next.id) {
currentSessionID = next.id
UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
messages = record.messages
} else {
currentSessionID = UUID()
UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
messages = []
}
}
}
func flushPendingSessionSave() {
sessionSaveTask?.cancel()
sessionSaveTask = nil
saveCurrentSession()
}
func setAllSkills(enabled: Bool) {
for i in skillEntries.indices {
skillEntries[i].isEnabled = enabled
@@ -2401,6 +2519,198 @@ class AgentEngine {
return results
}
// MARK: - Session Persistence
private func scheduleSessionSave() {
sessionSaveTask?.cancel()
let currentID = currentSessionID
sessionSaveTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled, let self, self.currentSessionID == currentID else { return }
self.saveCurrentSession()
}
}
private func saveCurrentSession() {
guard !messages.isEmpty else { return }
let summary = makeSessionSummary(
id: currentSessionID,
messages: messages
)
let record = ChatSessionRecord(
id: currentSessionID,
title: summary.title,
preview: summary.preview,
updatedAt: summary.updatedAt,
messages: messages
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .iso8601
do {
let directory = try ensureSessionsDirectory()
let data = try encoder.encode(record)
try data.write(to: sessionFileURL(for: currentSessionID), options: .atomic)
updateSessionSummary(summary)
persistSessionsIndex()
UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
_ = directory
} catch {
log("[History] save failed: \(error.localizedDescription)")
}
}
private func loadPersistedSessions() {
do {
_ = try ensureSessionsDirectory()
} catch {
log("[History] setup failed: \(error.localizedDescription)")
}
sessionSummaries = loadSessionsIndex().sorted { $0.updatedAt > $1.updatedAt }
if let record = loadSessionRecord(id: currentSessionID) {
messages = record.messages
updateSessionSummary(
.init(
id: record.id,
title: record.title,
preview: record.preview,
updatedAt: record.updatedAt
)
)
return
}
if let first = sessionSummaries.first, let record = loadSessionRecord(id: first.id) {
currentSessionID = first.id
UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
messages = record.messages
return
}
currentSessionID = UUID()
UserDefaults.standard.set(currentSessionID.uuidString, forKey: Self.currentSessionDefaultsKey)
messages = []
}
private func makeSessionSummary(id: UUID, messages: [ChatMessage]) -> ChatSessionSummary {
let firstUser = messages.first(where: { $0.role == .user })
let lastMeaningful = messages.reversed().first(where: { msg in
if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return true
}
return !msg.images.isEmpty || !msg.audios.isEmpty
})
let title = sessionTitle(from: firstUser)
let preview = sessionPreview(from: lastMeaningful ?? firstUser)
let updatedAt = (lastMeaningful ?? firstUser)?.timestamp ?? Date()
return ChatSessionSummary(
id: id,
title: title,
preview: preview,
updatedAt: updatedAt
)
}
private func sessionTitle(from message: ChatMessage?) -> String {
guard let message else { return "新会话" }
let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return String(trimmed.prefix(24))
}
if !message.images.isEmpty && !message.audios.isEmpty {
return "图片与语音会话"
}
if !message.images.isEmpty {
return "图片会话"
}
if !message.audios.isEmpty {
return "语音会话"
}
return "新会话"
}
private func sessionPreview(from message: ChatMessage?) -> String {
guard let message else { return "暂无内容" }
let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return String(trimmed.prefix(80))
}
if !message.images.isEmpty && !message.audios.isEmpty {
return "包含图片与语音"
}
if !message.images.isEmpty {
return "包含图片"
}
if !message.audios.isEmpty {
return "包含语音"
}
return "暂无内容"
}
private func updateSessionSummary(_ summary: ChatSessionSummary) {
if let index = sessionSummaries.firstIndex(where: { $0.id == summary.id }) {
sessionSummaries[index] = summary
} else {
sessionSummaries.append(summary)
}
sessionSummaries.sort { $0.updatedAt > $1.updatedAt }
}
private func persistSessionsIndex() {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .iso8601
do {
let data = try encoder.encode(sessionSummaries)
try data.write(to: sessionsIndexURL(), options: .atomic)
} catch {
log("[History] index save failed: \(error.localizedDescription)")
}
}
private func loadSessionsIndex() -> [ChatSessionSummary] {
let url = sessionsIndexURL()
guard let data = try? Data(contentsOf: url) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([ChatSessionSummary].self, from: data)) ?? []
}
private func loadSessionRecord(id: UUID) -> ChatSessionRecord? {
let url = sessionFileURL(for: id)
guard let data = try? Data(contentsOf: url) else { return nil }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try? decoder.decode(ChatSessionRecord.self, from: data)
}
private func ensureSessionsDirectory() throws -> URL {
let directory = sessionsDirectoryURL()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
private func sessionsIndexURL() -> URL {
sessionsDirectoryURL().appendingPathComponent(sessionsIndexFileName)
}
private func sessionFileURL(for id: UUID) -> URL {
sessionsDirectoryURL().appendingPathComponent("\(id.uuidString).json")
}
private func sessionsDirectoryURL() -> URL {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appDir = supportDir.appendingPathComponent("PhoneClaw", isDirectory: true)
return appDir.appendingPathComponent(sessionsDirectoryName, isDirectory: true)
}
private func extractSkillName(from text: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: "\"name\"\\s*:\\s*\"([^\"]+)\""),
let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
+11
View File
@@ -339,6 +339,12 @@ public class MLXLocalLLMService: LLMEngine {
self.modelInstallStates[id] = .downloaded
self.refreshModelInstallStates()
}
} catch is CancellationError {
try? fm.removeItem(at: partialDirectory)
await MainActor.run {
self.modelInstallStates[id] = .notInstalled
self.refreshModelInstallStates()
}
} catch {
try? fm.removeItem(at: partialDirectory)
await MainActor.run {
@@ -355,6 +361,11 @@ public class MLXLocalLLMService: LLMEngine {
await task.value
}
public func cancelModelDownload(id: String) {
guard currentDownloadTasks[id] != nil else { return }
currentDownloadTasks[id]?.cancel()
}
func loadModel() {
currentLoadTask?.cancel()
currentLoadTask = Task { [weak self] in
+10 -10
View File
@@ -40,7 +40,6 @@
C2B3C4D5E6F718293041B2C3 /* ToolRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B3C4D5E6F718293041B2C4 /* ToolRegistry.swift */; };
C3C4D5E6F7081A2B3C52D3E4 /* ChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C4D5E6F7081A2B3C52D3E5 /* ChatModels.swift */; };
C4D5E6F70819A2B3C463E4F5 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D5E6F70819A2B3C463E4F6 /* Theme.swift */; };
D5E6F7081A2B3C4D5E6F7087 /* gemma-4-e4b-it-4bit in Resources */ = {isa = PBXBuildFile; fileRef = D5E6F7081A2B3C4D5E6F7086 /* gemma-4-e4b-it-4bit */; };
ICONASSETS0000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ICONASSETS0000000000001 /* Assets.xcassets */; };
/* End PBXBuildFile section */
@@ -284,15 +283,14 @@
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
E6F7081A2B3C4D5E6F708100 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D5E6F7081A2B3C4D5E6F7087 /* gemma-4-e4b-it-4bit in Resources */,
ICONASSETS0000000000002 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
E6F7081A2B3C4D5E6F708100 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
ICONASSETS0000000000002 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -484,6 +482,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = PhoneClaw.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZP4QNJ35TH;
INFOPLIST_FILE = Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -503,6 +502,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = PhoneClaw.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZP4QNJ35TH;
INFOPLIST_FILE = Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
+23 -10
View File
@@ -386,6 +386,7 @@ struct ConfigurationsView: View {
modelBadge(localized("检查中", "Checking"))
case .downloading(let completedFiles, let totalFiles, _):
downloadProgressBadge(
modelID: model.id,
completedFiles: completedFiles,
totalFiles: totalFiles
)
@@ -418,22 +419,34 @@ struct ConfigurationsView: View {
}
private func downloadProgressBadge(
modelID: String,
completedFiles: Int,
totalFiles: Int
) -> some View {
let safeTotal = max(totalFiles, 1)
let value = Double(min(completedFiles, safeTotal))
return VStack(alignment: .trailing, spacing: 4) {
Text(localized(
"下载中 \(completedFiles)/\(totalFiles)",
"Downloading \(completedFiles)/\(totalFiles)"
))
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textTertiary)
return HStack(spacing: 8) {
VStack(alignment: .trailing, spacing: 4) {
Text(localized(
"下载中 \(completedFiles)/\(totalFiles)",
"Downloading \(completedFiles)/\(totalFiles)"
))
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textTertiary)
ProgressView(value: value, total: Double(safeTotal))
.progressViewStyle(.linear)
.frame(width: 110)
ProgressView(value: value, total: Double(safeTotal))
.progressViewStyle(.linear)
.frame(width: 110)
}
Button(localized("取消", "Cancel")) {
engine.llm.cancelModelDownload(id: modelID)
}
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textPrimary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.bg, in: Capsule())
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
+167 -9
View File
@@ -24,6 +24,7 @@ struct ContentView: View {
@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<UUID> = []
/// 记录每个 THINK 卡片的展开状态(key = ResponseBlock.id)
@@ -101,9 +102,13 @@ struct ContentView: View {
audioCapture.refreshPermissionStatus()
return
}
engine.flushPendingSessionSave()
engine.cancelActiveGeneration()
_ = audioCapture.stopCapture()
}
.sheet(isPresented: $showHistory) {
SessionHistorySheet(engine: engine)
}
.sheet(isPresented: $showConfigurations) {
ConfigurationsView(engine: engine)
}
@@ -191,9 +196,12 @@ struct ContentView: View {
private var topBar: some View {
HStack(spacing: 0) {
// 左:新会话
Button(action: { engine.clearMessages() }) {
Image(systemName: "square.and.pencil")
// 左:历史/新会话
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)
@@ -364,17 +372,38 @@ struct ContentView: View {
.onSubmit { Task { await send() } }
#endif
Button { Task { await send() } } label: {
Image(systemName: "arrow.up")
Button {
if canCancelGeneration {
engine.cancelActiveGeneration()
} else {
Task { await send() }
}
} label: {
Image(systemName: canCancelGeneration ? "stop.fill" : "arrow.up")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(canSend ? Theme.bg : Theme.textTertiary)
.foregroundStyle(
canCancelGeneration || canSend
? Theme.bg
: Theme.textTertiary
)
.frame(width: 34, height: 34)
.background(canSend ? Theme.accent : Theme.bgElevated, in: Circle())
.overlay(Circle().strokeBorder(canSend ? .clear : Theme.border, lineWidth: 1))
.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)
.disabled(!canSend && !canCancelGeneration)
.animation(.easeInOut(duration: 0.15), value: canSend)
.animation(.easeInOut(duration: 0.15), value: canCancelGeneration)
}
.padding(.horizontal, Theme.inputPadH)
.padding(.vertical, 14)
@@ -470,6 +499,10 @@ struct ContentView: View {
&& !engine.isProcessing && engine.llm.isLoaded
}
private var canCancelGeneration: Bool {
engine.isProcessing || engine.llm.isGenerating
}
private func send() async {
let text = inputText
let images = selectedImages
@@ -500,6 +533,131 @@ struct ContentView: View {
}
}
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 {