feat: ship shell app and chat session polish
This commit is contained in:
1 parent
271ac8c1dc
commit
69c4b32248
5 files changed
+532
-40
No files matched your search
+321
-11
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user