diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 16e2cd2..83b6026 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -470,11 +470,39 @@ struct QuizSubmitResponse: Codable { // MARK: - RAG Chat +// MARK: - Chat Scope + +enum ChatScopeType: String, Codable { + case knowledgeBase = "knowledge_base" + case folder = "folder" + case material = "material" + case knowledgeItem = "knowledge_item" + case global = "global" +} + +struct ChatEntryContext { + let scopeType: ChatScopeType + let scopeId: String? + let scopeName: String + let parentKnowledgeBaseId: String? + let createdFrom: String +} + struct ChatSession: Codable, Identifiable { let id: String let userId: String? let knowledgeBaseId: String? + let scopeType: String? + let scopeId: String? + let parentKnowledgeBaseId: String? let title: String? + let createdFrom: String? + let isPinned: Bool? + let isArchived: Bool? + let isDeleted: Bool? + let modelMode: String? + let modelId: String? + let lastMessageAt: String? let createdAt: String? let updatedAt: String? } @@ -485,23 +513,44 @@ struct ChatMessage: Codable, Identifiable { let role: String let content: String let tokens: Int? + let scopeSnapshot: ChatScopeSnapshot? let createdAt: String? let citations: [ChatCitation]? } +struct ChatScopeSnapshot: Codable { + let scopeType: String? + let scopeId: String? + let parentKnowledgeBaseId: String? +} + struct ChatCitation: Codable, Identifiable { let id: String let chunkId: String? + let sourceId: String? let sourceTitle: String? let excerptText: String? let pageNumber: Int? + let lineStart: Int? + let lineEnd: Int? } struct CreateSessionRequest: Codable { - let knowledgeBaseId: String + let scopeType: String + let scopeId: String? + let parentKnowledgeBaseId: String? + let createdFrom: String let title: String? } +struct UpdateChatSessionRequest: Codable { + var title: String? + var isPinned: Bool? + var isArchived: Bool? + var modelMode: String? + var modelId: String? +} + struct SendMessageRequest: Codable { let content: String } @@ -512,7 +561,7 @@ struct SendMessageResponse: Codable { let content: String? let tokens: Int? let blocked: Bool? - let message: String? + let message: ChatMessage? let citations: [ChatCitation]? } diff --git a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift index d429591..38c24b0 100644 --- a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift +++ b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift @@ -2,7 +2,7 @@ import SwiftUI enum Route: Hashable { // AI - case aiChat + case aiChat(context: ChatEntryContext? = nil) case dailyThinking case aiFeedback case activeRecall @@ -30,6 +30,10 @@ enum Route: Hashable { case quizTake(quizId: String) case quizResult(quizId: String, attemptId: String) + // Material Reader + case materialReader(materialId: String, filePath: String, materialType: MaterialType) + case materialDetail(knowledgeBaseId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?) + // Profile case notificationList case settings @@ -40,38 +44,90 @@ enum Route: Hashable { } extension Route { - @ViewBuilder - var destination: some View { + var label: String { switch self { - case .aiChat: AIChatPage() - case .dailyThinking: DailyThinkingPage() - case .aiFeedback: AIFeedbackPageView() - case .activeRecall: ActiveRecallView() - case .weakPoints: WeakPointsPage() - case .reviewCard: ReviewCardView() - - case .librarySearch: LibrarySearchView() - case .libraryDetail(let id): LibraryDetailPage(knowledgeBaseId: id) - case .libraryImport: ImportPage() - case .libraryCreate: CreateLibraryPage() - case .addKnowledge(let id): AddKnowledgePage(knowledgeBaseId: id) - case .knowledgeDetail(let item): KnowledgeDetailPage(item: item) - case .editKnowledge(let item): EditKnowledgePage(item: item) - - case .learningSession(let title, let type, let colorHex): - LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex)) - case .studyHome: StudyHomeView(selectedTab: .constant("study")) - - case .notificationList: NotificationListView() - case .settings: SettingsView() - case .goalSetting: GoalSettingDetailView() - case .methodPreference: MethodPreferenceView() - case .feedbackForm: FeedbackFormView() - case .editProfile: EditProfilePage() - case .importReview(let sourceId): ImportReviewPage(sourceId: sourceId) - case .quizList(let kbId): QuizListView(knowledgeBaseId: kbId) - case .quizTake(let id): QuizTakerView(quizId: id) - case .quizResult(let qid, let aid): QuizResultView(quizId: qid, attemptId: aid) + case .aiChat: "AIChatPage" + case .dailyThinking: "DailyThinkingPage" + case .aiFeedback: "AIFeedbackPageView" + case .activeRecall: "ActiveRecallView" + case .weakPoints: "WeakPointsPage" + case .reviewCard: "ReviewCardView" + case .librarySearch: "LibrarySearchView" + case .libraryDetail: "LibraryDetailPage" + case .libraryImport: "ImportPage" + case .libraryCreate: "CreateLibraryPage" + case .addKnowledge: "AddKnowledgePage" + case .knowledgeDetail: "KnowledgeDetailPage" + case .editKnowledge: "EditKnowledgePage" + case .learningSession: "LearningSessionView" + case .studyHome: "StudyHomeView" + case .notificationList: "NotificationListView" + case .settings: "SettingsView" + case .goalSetting: "GoalSettingDetailView" + case .methodPreference: "MethodPreferenceView" + case .feedbackForm: "FeedbackFormView" + case .editProfile: "EditProfilePage" + case .importReview: "ImportReviewPage" + case .quizList: "QuizListView" + case .quizTake: "QuizTakerView" + case .quizResult: "QuizResultView" + case .materialReader: "MaterialReaderView" + case .materialDetail: "MaterialDetailView" } } + + @ViewBuilder + var destination: some View { + let content: AnyView = { + switch self { + case .aiChat(let ctx): AnyView(AIChatPage(context: ctx)) + case .dailyThinking: AnyView(DailyThinkingPage()) + case .aiFeedback: AnyView(AIFeedbackPageView()) + case .activeRecall: AnyView(ActiveRecallView()) + case .weakPoints: AnyView(WeakPointsPage()) + case .reviewCard: AnyView(ReviewCardView()) + + case .librarySearch: AnyView(LibrarySearchView()) + case .libraryDetail(let id): AnyView(LibraryDetailPage(knowledgeBaseId: id)) + case .libraryImport: AnyView(ImportPage()) + case .libraryCreate: AnyView(CreateLibraryPage()) + case .addKnowledge(let id): AnyView(AddKnowledgePage(knowledgeBaseId: id)) + case .knowledgeDetail(let item): AnyView(KnowledgeDetailPage(item: item)) + case .editKnowledge(let item): AnyView(EditKnowledgePage(item: item)) + + case .learningSession(let title, let type, let colorHex): + AnyView(LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex))) + case .studyHome: AnyView(StudyHomeView(selectedTab: .constant("study"))) + + case .notificationList: AnyView(NotificationListView()) + case .settings: AnyView(SettingsView()) + case .goalSetting: AnyView(GoalSettingDetailView()) + case .methodPreference: AnyView(MethodPreferenceView()) + case .feedbackForm: AnyView(FeedbackFormView()) + case .editProfile: AnyView(EditProfilePage()) + case .importReview(let sourceId): AnyView(ImportReviewPage(sourceId: sourceId)) + case .quizList(let kbId): AnyView(QuizListView(knowledgeBaseId: kbId)) + case .quizTake(let id): AnyView(QuizTakerView(quizId: id)) + case .quizResult(let qid, let aid): AnyView(QuizResultView(quizId: qid, attemptId: aid)) + case .materialReader(let mid, let path, let mt): AnyView(MaterialReaderView(materialId: mid, filePath: path, materialType: mt)) + case .materialDetail(let kbId, let name, let type, let size, let path, let date): + AnyView(MaterialDetailView(knowledgeBaseId: kbId, fileName: name, fileType: type, fileSize: size, filePath: path, uploadDate: date)) + } + }() + + #if DEBUG + content.overlay(alignment: .topTrailing) { + Text(label) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.black.opacity(0.55)) + .cornerRadius(4) + .padding(.top, 60).padding(.trailing, 4) + .allowsHitTesting(false) + } + #else + content + #endif + } } diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index fb82647..8c9a76c 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -344,13 +344,23 @@ class RagChatService { static let shared = RagChatService() private let client = APIClient.shared - func createSession(knowledgeBaseId: String, title: String? = nil) async throws -> ChatSession { - let body = CreateSessionRequest(knowledgeBaseId: knowledgeBaseId, title: title) + func createSession(ctx: ChatEntryContext) async throws -> ChatSession { + let body = CreateSessionRequest( + scopeType: ctx.scopeType.rawValue, + scopeId: ctx.scopeId, + parentKnowledgeBaseId: ctx.parentKnowledgeBaseId, + createdFrom: ctx.createdFrom, + title: nil + ) return try await client.request("/rag-chat/sessions", method: "POST", body: body) } - func listSessions() async throws -> [ChatSession] { - return try await client.request("/rag-chat/sessions") + func listSessions(scopeType: String? = nil, scopeId: String? = nil, parentKnowledgeBaseId: String? = nil) async throws -> [ChatSession] { + var items: [URLQueryItem] = [] + if let st = scopeType { items.append(URLQueryItem(name: "scopeType", value: st)) } + if let si = scopeId { items.append(URLQueryItem(name: "scopeId", value: si)) } + if let pk = parentKnowledgeBaseId { items.append(URLQueryItem(name: "parentKnowledgeBaseId", value: pk)) } + return try await client.request("/rag-chat/sessions", queryItems: items.isEmpty ? nil : items) } func getMessages(sessionId: String) async throws -> [ChatMessage] { @@ -362,9 +372,50 @@ class RagChatService { return try await client.request("/rag-chat/sessions/\(sessionId)/messages", method: "POST", body: body) } + func updateSession(id: String, dto: UpdateChatSessionRequest) async throws -> ChatSession { + return try await client.request("/rag-chat/sessions/\(id)", method: "PATCH", body: dto) + } + func deleteSession(id: String) async throws { let _: GenericSuccessResponse = try await client.request("/rag-chat/sessions/\(id)", method: "DELETE") } + + struct SSEChunk: Decodable { + let type: String + let content: String? + } + + func sendMessageStream(sessionId: String, content: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + let token = await APIClient.shared.getToken() ?? "" + let url = URL(string: "\(APIConfig.baseURL)/rag-chat/sessions/\(sessionId)/stream")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONEncoder().encode(SendMessageRequest(content: content)) + + let session = URLSession(configuration: .default) + let (bytes, _) = try await session.bytes(for: request) + + // Read SSE lines properly — URLSession.bytes is character-based (UTF-8 safe) + var lineBuffer = "" + for try await line in bytes.lines { + if line.hasPrefix("data: "), let jsonData = line.dropFirst(6).data(using: .utf8) { + if let chunk = try? JSONDecoder().decode(SSEChunk.self, from: jsonData) { + continuation.yield(chunk) + if chunk.type == "done" || chunk.type == "error" { + continuation.finish() + return + } + } + } + } + continuation.finish() + } + } + } } // MARK: - Document Import diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift index 5e67a56..11abeb5 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift @@ -2,10 +2,18 @@ import SwiftUI import UIKit struct AIChatPage: View { - @StateObject private var vm = AIChatViewModel() + @StateObject private var vm: AIChatViewModel @State private var showSessions = false @State private var sessions: [ChatSession] = [] + init(context: ChatEntryContext? = nil) { + let vm = AIChatViewModel() + if let ctx = context { + vm.setEntryContext(ctx) + } + _vm = StateObject(wrappedValue: vm) + } + var body: some View { ZStack { Color.zxBg0.ignoresSafeArea() @@ -23,46 +31,38 @@ struct AIChatPage: View { } else { ScrollViewReader { proxy in ScrollView { - VStack(spacing: 16) { + VStack(spacing: 12) { + // Scope indicator + if let label = vm.scopeLabel { + HStack(spacing: 6) { + Image(systemName: vm.scopeIcon) + .font(.system(size: 11)) + Text(label) + .font(.system(size: 12, weight: .medium)) + } + .foregroundColor(Color.zxF03) + .padding(.horizontal, 12).padding(.vertical, 6) + .background(Color.zxFill003) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.top, 4) + } + ForEach(vm.messages) { m in - VStack(alignment: m.role == .user ? .trailing : .leading, spacing: 6) { - chatBubble(m) - // Citations below AI messages - if m.role == .ai, let citations = m.citations, !citations.isEmpty { - VStack(spacing: 4) { - ForEach(citations.prefix(3)) { c in - HStack(spacing: 4) { - Image(systemName: "doc").font(.system(size: 10)).foregroundColor(Color.zxF04) - Text(c.excerptText?.prefix(60).description ?? "").font(.system(size: 10)).foregroundColor(Color.zxF04).lineLimit(1) - }.padding(.horizontal, 8).padding(.vertical, 4) - .background(Color.zxFill004).clipShape(Capsule()) - } - }.padding(.leading, 36) - } - // Actions for AI messages - if m.role == .ai { - HStack(spacing: 16) { - Button { UIPasteboard.general.string = m.content } label: { - Label("复制", systemImage: "doc.on.doc").font(.system(size: 12)).foregroundColor(Color.zxF04) - } - Button { Task { vm.send() } } label: { - Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 12)).foregroundColor(Color.zxF04) - } - }.padding(.leading, 36) - } - }.id(m.id).padding(.horizontal, 20) + MessageCard(message: m, onRegenerate: { Task { vm.send() } }) + .id(m.id) + .padding(.horizontal, 20) } - if vm.isSending { - HStack(spacing: 8) { - Image("icon-brain").foregroundColor(Color.zxPurple) - .frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle()) - ProgressView().tint(Color.zxPurple).padding(.leading, 4); Spacer() - }.padding(.horizontal, 20) + if vm.isSending && vm.messages.last?.role == .user { + HStack { ProgressView().tint(Color.zxPurple).padding(); Spacer() } + .padding(.horizontal, 20) } - }.padding(.top, 8).padding(.bottom, 100) + } + .padding(.top, 8).padding(.bottom, 100) } .scrollIndicators(.hidden) - .onChange(of: vm.messages.count) { _ in withAnimation { proxy.scrollTo(vm.messages.last?.id) } } + .onChange(of: vm.messages.count) { _ in scrollToBottom(proxy) } + .onChange(of: vm.messages.last?.content) { _ in scrollToBottom(proxy) } + .onChange(of: vm.messages.last?.thinkingContent) { _ in scrollToBottom(proxy) } } ZXAIInputBar(text: $vm.inputText, onSend: { vm.send() }).padding(.horizontal, 20).padding(.bottom, 34) } @@ -72,56 +72,164 @@ struct AIChatPage: View { .toolbarBackground(.hidden, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { Task { sessions = (try? await RagChatService.shared.listSessions()) ?? []; showSessions = true } } label: { - Image("icon-list").font(.system(size: 16)).foregroundColor(Color.zxF05) + HStack(spacing: 12) { + Button { Task { await vm.createNewSession() } } label: { + Image(systemName: "plus.bubble").font(.system(size: 16)).foregroundColor(Color.zxF05) + } + Button { showSessions = true } label: { + Image("icon-list").font(.system(size: 16)).foregroundColor(Color.zxF05) + } } } } .sheet(isPresented: $showSessions) { VStack(spacing: 0) { - RoundedRectangle(cornerRadius: 3).fill(Color.zxF03).frame(width: 36, height: 5).padding(.top, 12).padding(.bottom, 16) - Text("对话列表").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).padding(.bottom, 16) + HStack { + Text("对话列表").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0) + Spacer() + Button { + showSessions = false + Task { await vm.createNewSession() } + } label: { + Image(systemName: "plus.bubble").font(.system(size: 18)).foregroundColor(Color.zxF05) + } + }.padding(.horizontal, 20).padding(.top, 16).padding(.bottom, 12) if sessions.isEmpty { Text("暂无历史对话").font(.system(size: 14)).foregroundColor(Color.zxF04).padding(.top, 40) } else { - ScrollView { - VStack(spacing: 0) { - ForEach(sessions) { s in - Button { - showSessions = false - Task { await vm.loadSession(s.id) } - } label: { - HStack { + List { + ForEach(sessions) { s in + Button { + showSessions = false + Task { await vm.loadSession(s.id) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { Text(s.title ?? "对话").font(.system(size: 14)).foregroundColor(Color.zxF0) - Spacer() - Text(s.updatedAt?.prefix(10).description ?? "").font(.system(size: 12)).foregroundColor(Color.zxF04) - }.padding(.horizontal, 20).padding(.vertical, 14) - }.foregroundColor(.primary) + Text(s.updatedAt?.prefix(10).description ?? "").font(.system(size: 11)).foregroundColor(Color.zxF04) + } + Spacer() + }.padding(.vertical, 4) + }.foregroundColor(.primary) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { + try? await RagChatService.shared.deleteSession(id: s.id) + sessions.removeAll { $0.id == s.id } + } + } label: { Label("删除", systemImage: "trash") } } } } + .listStyle(.plain) + .scrollContentBackground(.hidden) } } .frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxBg0) .presentationDetents([.medium, .large]) + .task { + if let s = try? await RagChatService.shared.listSessions() { sessions = s } + } } .task { await vm.load() } } - private func chatBubble(_ m: AIMessage) -> some View { - HStack(alignment: .top, spacing: 8) { - if m.role == .ai { - Image("icon-brain").foregroundColor(Color.zxPurple) - .frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle()) - } - Text(m.content).zxFontScaled(size: 14) - .foregroundColor(m.role == .user ? .white : Color.zxF007).padding(12) - .background(m.role == .user ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill004)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - if m.role == .user { - Circle().frame(width: 28, height: 28) - .foregroundColor(Color.zxPurpleBG(0.2)).overlay(Text("我").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple)) - } - }.frame(maxWidth: .infinity, alignment: m.role == .user ? .trailing : .leading) + private func scrollToBottom(_ proxy: ScrollViewProxy) { + withAnimation { + proxy.scrollTo(vm.messages.last?.id, anchor: .bottom) + } } } + +// MARK: - Message Card + +struct MessageCard: View { + let message: AIMessage + let onRegenerate: () -> Void + @State private var thinkingExpanded = true + + var body: some View { + let isAI = message.role == .ai + let hasContent = !message.content.isEmpty + let hasThinking = !(message.thinkingContent?.isEmpty ?? true) + let isStreaming = message.isStreaming + + // Don't render empty placeholder bubbles + if !hasContent && !hasThinking { EmptyView() } + else { + VStack(alignment: isAI ? .leading : .trailing, spacing: 0) { + // Thinking process (inside card, at top) + if hasThinking { + ThinkingSection(text: message.thinkingContent ?? "", isExpanded: $thinkingExpanded, isStreaming: isStreaming) + if hasContent { Divider().background(Color.zxF03).padding(.horizontal, 8) } + } + + // Content + if hasContent { + Text(message.content).zxFontScaled(size: 14) + .foregroundColor(isAI ? Color.zxF007 : .white) + .padding(12) + } + + // Actions (AI only, after streaming done) + if isAI && !isStreaming && hasContent { + HStack(spacing: 16) { + Button { UIPasteboard.general.string = message.content } label: { + Label("复制", systemImage: "doc.on.doc").font(.system(size: 12)).foregroundColor(Color.zxF04) + } + Button { onRegenerate() } label: { + Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 12)).foregroundColor(Color.zxF04) + } + Spacer() + }.padding(.horizontal, 12).padding(.bottom, 8) + } + + // Loading indicator + if isAI && isStreaming && !hasContent && hasThinking { + HStack { ProgressView().tint(Color.zxPurple).scaleEffect(0.8); Spacer() } + .padding(.horizontal, 12).padding(.bottom, 8) + } + } + .frame(maxWidth: .infinity, alignment: isAI ? .leading : .trailing) + .background(isAI ? AnyView(Color.zxFill004) : AnyView(ZXGradient.brandPurple)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } +} + +// MARK: - Thinking Section + +struct ThinkingSection: View { + let text: String + @Binding var isExpanded: Bool + let isStreaming: Bool + + var body: some View { + VStack(spacing: 0) { + // Collapse toggle + Button { + withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() } + } label: { + HStack(spacing: 4) { + Text(isExpanded ? "思考中..." : "思考完成") + .font(.system(size: 11)).foregroundColor(Color.zxF03) + if isStreaming { ProgressView().scaleEffect(0.5).tint(Color.zxF03) } + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 8)).foregroundColor(Color.zxF03) + } + .padding(.horizontal, 12).padding(.vertical, 6) + } + + // Thinking content + if isExpanded { + Text(text) + .font(.system(size: 12)).foregroundColor(Color.zxF03) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.bottom, 8) + } + } + .background(Color.zxFill003) + } + +} diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift index adf3a83..e1f6ecf 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift @@ -2,10 +2,21 @@ import Combine import Foundation struct AIMessage: Identifiable { - let id = UUID() + let id: UUID let role: AIMessageRole let content: String + let thinkingContent: String? let citations: [ChatCitation]? + let isStreaming: Bool + + init(role: AIMessageRole, content: String, thinkingContent: String? = nil, citations: [ChatCitation]? = nil, isStreaming: Bool = false, id: UUID = UUID()) { + self.id = id + self.role = role + self.content = content + self.thinkingContent = thinkingContent + self.citations = citations + self.isStreaming = isStreaming + } } struct AIMessageCitation: Identifiable { @@ -27,7 +38,8 @@ final class AIChatViewModel: ObservableObject { @Published var sessionError: String? private var sessionId: String? - private var knowledgeBaseId: String? + private var entryContext: ChatEntryContext? + private var itemIds: [String]? var canSend: Bool { !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending && sessionId != nil @@ -35,27 +47,64 @@ final class AIChatViewModel: ObservableObject { var isReady: Bool { sessionId != nil } + var scopeLabel: String? { + guard let ctx = entryContext, ctx.scopeType != .global else { return nil } + return "提问范围:\(ctx.scopeName)" + } + + var scopeIcon: String { + guard let ctx = entryContext else { return "globe" } + switch ctx.scopeType { + case .knowledgeBase: return "books.vertical" + case .folder: return "folder" + case .material: return "doc.text" + case .knowledgeItem: return "lightbulb" + case .global: return "globe" + } + } + func load() async { guard sessionId == nil else { return } - // 自动选一个知识库 - if knowledgeBaseId == nil { - do { - let kbs = try await KnowledgeBaseService.shared.list(page: 1, limit: 1) - knowledgeBaseId = kbs.first?.id - } catch {} - } - - guard let kbId = knowledgeBaseId else { - sessionError = "请先创建一个知识库,才能使用 AI 对话" - return - } - isCreatingSession = true + + let ctx = entryContext ?? ChatEntryContext( + scopeType: .global, + scopeId: nil, + scopeName: "AI 对话", + parentKnowledgeBaseId: nil, + createdFrom: "global_ai_entry" + ) + do { - let session = try await RagChatService.shared.createSession(knowledgeBaseId: kbId, title: nil) + // open-or-create: backend handles find-or-create logic + let session = try await RagChatService.shared.createSession(ctx: ctx) + await loadSession(session.id) + } catch { + sessionError = "创建对话失败" + isCreatingSession = false + } + } + + func createNewSession() async { + sessionId = nil + messages = [] + isCreatingSession = true + + // Use entry context if available, otherwise default to global + let ctx = entryContext ?? ChatEntryContext( + scopeType: .global, + scopeId: nil, + scopeName: "AI 对话", + parentKnowledgeBaseId: nil, + createdFrom: "global_ai_entry" + ) + + do { + let session = try await RagChatService.shared.createSession(ctx: ctx) sessionId = session.id - messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于你的知识库回答问题。", citations: nil)] + let scopeLabel = ctx.scopeType != .global ? "「\(ctx.scopeName)」的" : "" + messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于\(scopeLabel)内容回答问题。")] isCreatingSession = false } catch { sessionError = "创建对话失败" @@ -66,34 +115,64 @@ final class AIChatViewModel: ObservableObject { func send() { guard canSend, let sid = sessionId else { return } let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) - messages.append(AIMessage(role: .user, content: text, citations: nil)) + messages.append(AIMessage(role: .user, content: text)) inputText = "" isSending = true + let placeholderId = UUID() + messages.append(AIMessage(role: .ai, content: "", isStreaming: true, id: placeholderId)) + Task { do { - let resp = try await RagChatService.shared.sendMessage(sessionId: sid, content: text) - if resp.blocked == true { - messages.append(AIMessage(role: .ai, content: resp.message ?? "内容违规,请修改后重试", citations: nil)) - } else { - messages.append(AIMessage(role: .ai, content: resp.content ?? "抱歉,AI 暂时无法回复", citations: resp.citations)) + let stream = RagChatService.shared.sendMessageStream(sessionId: sid, content: text) + var thinking = "" + var reply = "" + + for try await chunk in stream { + switch chunk.type { + case "thinking": + thinking += chunk.content ?? "" + if let idx = messages.firstIndex(where: { $0.id == placeholderId }) { + messages[idx] = AIMessage(role: .ai, content: reply, thinkingContent: thinking, isStreaming: true, id: placeholderId) + } + case "content": + reply += chunk.content ?? "" + if let idx = messages.firstIndex(where: { $0.id == placeholderId }) { + messages[idx] = AIMessage(role: .ai, content: reply, thinkingContent: thinking, isStreaming: true, id: placeholderId) + } + case "error": + if let idx = messages.firstIndex(where: { $0.id == placeholderId }) { + messages[idx] = AIMessage(role: .ai, content: "请求失败: \(chunk.content ?? "未知错误")") + } + case "done": + if let idx = messages.firstIndex(where: { $0.id == placeholderId }) { + messages[idx] = AIMessage(role: .ai, content: reply, thinkingContent: thinking.isEmpty ? nil : thinking) + } + default: break + } } } catch { - messages.append(AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)", citations: nil)) + if let idx = messages.firstIndex(where: { $0.id == placeholderId }) { + messages[idx] = AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)") + } } isSending = false } } - func setKnowledgeBase(_ id: String) { knowledgeBaseId = id } + func setEntryContext(_ ctx: ChatEntryContext) { entryContext = ctx } func loadSession(_ id: String) async { sessionId = id isCreatingSession = true do { let msgs: [ChatMessage] = try await RagChatService.shared.getMessages(sessionId: id) - messages = msgs.map { m in - AIMessage(role: m.role == "user" ? .user : .ai, content: m.content, citations: m.citations) + if msgs.isEmpty { + messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于你的知识库回答问题。")] + } else { + messages = msgs.map { m in + AIMessage(role: m.role == "user" ? .user : .ai, content: m.content, citations: m.citations) + } } } catch { sessionError = "加载对话失败" } isCreatingSession = false diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPageView.swift b/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPageView.swift index 345e67c..fdd1605 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPageView.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIFeedbackPageView.swift @@ -71,7 +71,7 @@ struct AIFeedbackPageView: View { .shadow(color: Color(hex: "#7C6EFA", opacity: 0.3), radius: 24) } HStack(spacing: 12) { - NavigationLink(value: Route.aiChat) { + NavigationLink(value: Route.aiChat(context: nil)) { HStack(spacing: 4) { Text("深入提问").font(.system(size: 14)) Image("icon-chevron-right").resizable().scaledToFit().frame(width: 14, height: 14) diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift index 792bd80..49c50d8 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIHomeView.swift @@ -232,7 +232,7 @@ struct AIHomeView: View { } .foregroundColor(.primary) - NavigationLink(value: Route.aiChat) { + NavigationLink(value: Route.aiChat(context: nil)) { quickActionItem( icon: "mic", label: "费曼\n解释练习" diff --git a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift index 2ffca94..c233039 100644 --- a/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift +++ b/AIStudyApp/AIStudyApp/Features/Library/LibrarySubpages.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine import PhotosUI import UniformTypeIdentifiers @@ -391,6 +392,22 @@ struct LibraryDetailPage: View { Text("确定要删除选中的 \(selectedIds.count) 个知识点吗?此操作不可恢复。") } .task { await viewModel.loadItems(knowledgeBaseId: knowledgeBaseId) } + .safeAreaInset(edge: .bottom) { + NavigationLink(value: Route.aiChat(context: ChatEntryContext(scopeType: .knowledgeBase, scopeId: knowledgeBaseId, scopeName: "知识库", parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "knowledge_base_detail"))) { + HStack(spacing: 8) { + Image(systemName: "bubble.left.and.bubble.right").font(.system(size: 16)) + Text("AI 对话").font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(ZXGradient.brandPurple) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + .background(Color.zxBg0) + } } private func kbInfoHeader(_ kb: KnowledgeBase) -> some View { @@ -888,38 +905,203 @@ struct AddKnowledgePage: View { } } +// MARK: - Knowledge Detail ViewModel + +@MainActor +final class KnowledgeDetailViewModel: ObservableObject { + @Published var localPath: String? + @Published var detectedType: MaterialType? + @Published var loadState: LoadState = .loading + + enum LoadState { case loading, ready, error(String) } + + let item: KnowledgeItem + + init(item: KnowledgeItem) { self.item = item } + + var isURL: Bool { + guard let c = item.content else { return false } + return c.hasPrefix("http://") || c.hasPrefix("https://") + } + + func load() async { + print("[KD_DETAIL] load() start — item.id=\(item.id), title=\(item.title)") + print("[KD_DETAIL] isURL=\(isURL), content prefix=\(item.content?.prefix(200) ?? "nil")") + + guard isURL, let urlStr = item.content, let url = URL(string: urlStr) else { + // Not a URL — plain text content, ready immediately + let contentLen = item.content?.count ?? 0 + print("[KD_DETAIL] plain text path — content length=\(contentLen), setting .ready") + loadState = .ready + return + } + loadState = .loading + let ext = url.pathExtension.isEmpty ? "bin" : url.pathExtension.lowercased() + let localURL = FileManager.default.temporaryDirectory + .appendingPathComponent("kd_\(item.id).\(ext)") + print("[KD_DETAIL] URL download — ext=\(ext), local=\(localURL.path)") + + // Check cached file — skip if it's a COS error XML + if FileManager.default.fileExists(atPath: localURL.path) { + let fileSize = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int) ?? -1 + // Detect COS error responses (small XML files < 2KB) + if fileSize > 0 && fileSize < 2048 { + if let content = try? String(contentsOfFile: localURL.path, encoding: .utf8), + content.contains(""), content.contains("AccessDenied") { + print("[KD_DETAIL] cached file is COS error, deleting and re-fetching...") + try? FileManager.default.removeItem(at: localURL) + } + } + if FileManager.default.fileExists(atPath: localURL.path) { + print("[KD_DETAIL] file cached — size=\(fileSize), type=\(typeFromExtension(ext))") + localPath = localURL.path + detectedType = typeFromExtension(ext) + loadState = .ready + return + } + } + + // Re-fetch item from API to get a fresh signed URL + print("[KD_DETAIL] re-fetching item from API for fresh URL...") + let freshItem = try? await KnowledgeItemService.shared.detail(id: item.id) + let downloadURL: URL + if let fresh = freshItem, let freshStr = fresh.content, let freshURL = URL(string: freshStr) { + print("[KD_DETAIL] got fresh URL") + downloadURL = freshURL + } else { + print("[KD_DETAIL] re-fetch failed, using original URL") + downloadURL = url + } + + do { + let (data, _) = try await URLSession.shared.data(from: downloadURL) + print("[KD_DETAIL] download done — size=\(data.count)") + // If it's a COS error XML, show error + if data.count < 2048, let text = String(data: data, encoding: .utf8), text.contains("") { + print("[KD_DETAIL] downloaded content is an error XML: \(text.prefix(200))") + loadState = .error("文件访问已过期,请刷新后重试") + return + } + try data.write(to: localURL) + localPath = localURL.path + detectedType = typeFromExtension(ext) + print("[KD_DETAIL] saved — path=\(localURL.path), type=\(String(describing: detectedType))") + loadState = .ready + } catch { + print("[KD_DETAIL] download ERROR — \(error.localizedDescription)") + loadState = .error(error.localizedDescription) + } + } + + private func typeFromExtension(_ ext: String) -> MaterialType { + switch ext { + case "md", "markdown": return .markdown + case "txt", "text": return .text + case "pdf": return .pdf + case "png", "jpg", "jpeg", "webp", "gif": return .image + case "doc", "docx": return .word + case "xls", "xlsx": return .excel + case "ppt", "pptx": return .powerPoint + case "epub": return .epub + default: return .unknown + } + } +} + +// MARK: - Knowledge Detail Page + struct KnowledgeDetailPage: View { let item: KnowledgeItem + @StateObject private var vm: KnowledgeDetailViewModel + + @State private var showQuickLook = false + @State private var showNoteSheet = false + + init(item: KnowledgeItem) { + self.item = item + _vm = StateObject(wrappedValue: KnowledgeDetailViewModel(item: item)) + } + var body: some View { - ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) { - HStack { Spacer() - NavigationLink(value: Route.editKnowledge(item: item)) { - Image("icon-pencil").font(.system(size: 16)).foregroundColor(Color.zxF05) - .frame(width: 36, height: 36).background(Color(hex:"#FFFFFF",opacity:0.05)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) - } - }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 8) - ScrollView { VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - HStack { - if let itemType = item.itemType { ZXChip(text: itemType, color: Color.zxPurple) } - if let sourceType = item.sourceType { ZXChip(text: sourceType, color: Color.zxAccent) } + Group { + switch vm.loadState { + case .loading: + VStack(spacing: 12) { + ProgressView().tint(Color.zxPrimary) + Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) + }.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas) + case .error(let msg): + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxCoral) + Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04) + // Fallback: show summary or raw text + if let summary = item.summary, !summary.isEmpty { + ScrollView { Text(summary).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6).padding(20) } + } + Button("重试") { Task { await vm.load() } } + .font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary) + .padding(.horizontal, 24).padding(.vertical, 10) + .background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10)) + }.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas) + case .ready: + if let path = vm.localPath, let mt = vm.detectedType { + MaterialReaderView(materialId: item.id, filePath: path, materialType: mt, title: item.title, showQuickLook: $showQuickLook, showNoteSheet: $showNoteSheet) + } else if let content = item.content { + // Plain text content — render directly + ZStack { Color.zxCanvas.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let summary = item.summary, !summary.isEmpty { + Text(summary).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6) + .padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)) + } + Text(content).font(.system(size: 14)).foregroundColor(Color.zxF0).lineSpacing(6) + }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) + }.scrollIndicators(.hidden) + } + } + } + } + .navigationTitle(item.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .tabBar) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + NavigationLink(value: Route.aiChat(context: ChatEntryContext(scopeType: .knowledgeItem, scopeId: item.id, scopeName: item.title, parentKnowledgeBaseId: item.knowledgeBaseId, createdFrom: "knowledge_item_detail"))) { + Label("AI 对话", systemImage: "bubble.left.and.bubble.right") } - Text(item.title).font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0) - if let content = item.content { Text(content).font(.system(size: 14)).foregroundColor(Color.zxF007).lineSpacing(6) } - }.padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)) - HStack(spacing: 12) { NavigationLink(value: Route.studyHome) { - Label("开始复习", systemImage: "arrow.triangle.2.circlepath").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 44).background(ZXGradient.brandPurple).clipShape(RoundedRectangle(cornerRadius: 14)) + Label("学习", systemImage: "arrow.triangle.2.circlepath") } - NavigationLink(value: Route.aiChat) { - Label("费曼解释", systemImage: "mic").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF05).frame(maxWidth: .infinity).frame(height: 44).background(Color.zxFill005).clipShape(RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) + NavigationLink(value: Route.editKnowledge(item: item)) { + Label("编辑", systemImage: "pencil") } + if let mt = vm.detectedType { + let pm = previewMode(for: mt) + if pm == .nativeReader { + Button { showNoteSheet = true } label: { + Label("笔记", systemImage: "square.and.pencil") + } + } + if pm == .platformPreview { + Button { showQuickLook = true } label: { + Label("预览", systemImage: "eye") + } + } + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 16)) + .foregroundColor(Color.zxF05) } - }.padding(.horizontal, 20).padding(.bottom, 80) }.scrollIndicators(.hidden) } - }.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)} + } + } + .task { await vm.load() } + } } + struct ZXChip: View { let text: String; let color: Color var body: some View { Text(text).font(.system(size: 10, weight: .semibold)).foregroundColor(color).padding(.horizontal, 8).padding(.vertical, 2).background(color.opacity(0.12)).clipShape(Capsule()) } } diff --git a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift new file mode 100644 index 0000000..f8bdf79 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialDetailView.swift @@ -0,0 +1,277 @@ +import SwiftUI +import Combine + +// MARK: - ViewModel + +@MainActor +final class MaterialDetailViewModel: ObservableObject { + @Published var kb: KnowledgeBase? + @Published var itemCount: Int = 0 + @Published var quizCount: Int = 0 + @Published var isLoading = true + @Published var aiStatus: AIStatus = .processing + + enum AIStatus: Equatable { + case processing // AI 整理知识点中 + case ready // 知识点已生成,可开始学习 + case failed(String) + } + + let knowledgeBaseId: String + + init(knowledgeBaseId: String) { + self.knowledgeBaseId = knowledgeBaseId + } + + func load() async { + isLoading = true + do { + kb = try await KnowledgeBaseService.shared.detail(id: knowledgeBaseId) + } catch {} + + do { + let items = try await KnowledgeItemService.shared.list(knowledgeBaseId: knowledgeBaseId) + itemCount = items.count + } catch {} + + do { + let quizzes = try await QuizService.shared.list(knowledgeBaseId: knowledgeBaseId) + quizCount = quizzes.count + } catch {} + + aiStatus = itemCount > 0 ? .ready : .processing + isLoading = false + } +} + +// MARK: - Main View + +struct MaterialDetailView: View { + @StateObject private var vm: MaterialDetailViewModel + + let knowledgeBaseId: String + let fileName: String + let fileType: MaterialType + let fileSize: UInt64 + let filePath: String + let uploadDate: String? + + init(knowledgeBaseId: String, fileName: String, fileType: MaterialType, + fileSize: UInt64, filePath: String, uploadDate: String? = nil) { + self.knowledgeBaseId = knowledgeBaseId + self.fileName = fileName + self.fileType = fileType + self.fileSize = fileSize + self.filePath = filePath + self.uploadDate = uploadDate + _vm = StateObject(wrappedValue: MaterialDetailViewModel(knowledgeBaseId: knowledgeBaseId)) + } + + var body: some View { + ZStack { + Color.zxCanvas.ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + // MARK: - 原文件区域 + fileSection + + // MARK: - 学习内容区域 + learningSection + + Spacer().frame(height: 100) + } + .padding(.horizontal, 20).padding(.top, 8) + } + .scrollIndicators(.hidden) + } + .navigationTitle("资料详情") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .tabBar) + .toolbarBackground(.hidden, for: .navigationBar) + .task { await vm.load() } + } + + // MARK: - File section + + var fileSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 4) { + Image(systemName: "doc.fill").font(.system(size: 11)).foregroundColor(Color.zxF04) + Text("原文件").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF04) + .textCase(.uppercase).tracking(0.5) + } + + // Status banner + statusBanner + + // File metadata card + VStack(spacing: 0) { + fileInfoRow(icon: "doc", label: "文件名", value: fileName) + Divider().background(Color.zxBorder008).padding(.leading, 48) + fileInfoRow(icon: "info.circle", label: "格式", value: fileType.displayName) + Divider().background(Color.zxBorder008).padding(.leading, 48) + fileInfoRow(icon: "arrow.down.to.line", label: "大小", value: formatFileSize(fileSize)) + Divider().background(Color.zxBorder008).padding(.leading, 48) + if let date = uploadDate { + fileInfoRow(icon: "calendar", label: "上传时间", value: date) + } + } + .padding(16) + .background(Color.zxSurfaceElevated) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxHairline, lineWidth: 1)) + + // Read original file button + NavigationLink(value: Route.materialReader( + materialId: knowledgeBaseId, + filePath: filePath, + materialType: fileType + )) { + HStack(spacing: 8) { + Image(systemName: fileType == .pdf || fileType == .word || fileType == .excel ? "eye" : "book") + Text("阅读原文件") + } + .font(.system(size: 15, weight: .bold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity).frame(height: 48) + .background(ZXGradient.brand) + .clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg)) + } + } + } + + // MARK: - Status banner + + @ViewBuilder + var statusBanner: some View { + switch vm.aiStatus { + case .processing: + HStack(spacing: 10) { + ProgressView().tint(Color.zxAmber) + VStack(alignment: .leading, spacing: 2) { + Text("知习正在整理知识点").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxAmberDeep) + Text("完成后可开始学习和自测").font(.system(size: 12)).foregroundColor(Color.zxAmber) + } + Spacer() + } + .padding(12) + .background(Color.zxAmberSoft.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + case .ready: + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill").font(.system(size: 16)).foregroundColor(Color.zxGreen) + VStack(alignment: .leading, spacing: 2) { + Text("资料已上传,可以先阅读原文件").font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxGreen) + Text("知识点已整理完成").font(.system(size: 12)).foregroundColor(Color.zxF04) + } + Spacer() + } + .padding(12) + .background(Color.zxMintSoft.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + case .failed(let msg): + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 16)).foregroundColor(Color.zxCoral) + Text(msg).font(.system(size: 13)).foregroundColor(Color.zxCoral) + Spacer() + } + .padding(12) + .background(Color.zxCoralSoft.opacity(0.25)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + + // MARK: - File info row + + func fileInfoRow(icon: String, label: String, value: String) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 14)).foregroundColor(Color.zxF03) + .frame(width: 24, height: 24) + Text(label).font(.system(size: 13)).foregroundColor(Color.zxF04) + .frame(width: 60, alignment: .leading) + Text(value).font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF0) + .lineLimit(1) + Spacer() + } + .padding(.vertical, 8) + } + + // MARK: - Learning section + + var learningSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 4) { + Image(systemName: "brain.head.profile").font(.system(size: 11)).foregroundColor(Color.zxF04) + Text("学习内容").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF04) + .textCase(.uppercase).tracking(0.5) + } + + if vm.isLoading { + HStack { ProgressView().tint(Color.zxPrimary); Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) } + .frame(maxWidth: .infinity).padding(.vertical, 24) + } else { + // Stats row + HStack(spacing: 12) { + statBox(count: vm.itemCount, label: "知识点", icon: "lightbulb", color: Color.zxPurple) + statBox(count: vm.quizCount, label: "测验", icon: "list.bullet.clipboard", color: Color.zxAccent) + } + + // Action buttons + HStack(spacing: 12) { + NavigationLink(value: Route.aiChat(context: ChatEntryContext(scopeType: .material, scopeId: nil, scopeName: fileName, parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "material_detail"))) { + Label("问这份资料", systemImage: "bubble.left.and.bubble.right") + .font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0) + .frame(maxWidth: .infinity).frame(height: 44) + .background(Color.zxFill005) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) + } + + NavigationLink(value: Route.learningSession( + taskTitle: vm.kb?.title ?? "学习资料", + taskType: "study", + taskColorHex: "#3D7FFB" + )) { + Text("开始学习") + .font(.system(size: 14, weight: .bold)).foregroundColor(.white) + .frame(maxWidth: .infinity).frame(height: 44) + .background(ZXGradient.brandPurple) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + + if vm.aiStatus == .processing { + Text("知习正在整理知识点,完成后可开始学习和自测") + .font(.system(size: 12)).foregroundColor(Color.zxF03) + .frame(maxWidth: .infinity, alignment: .center).padding(.top, 4) + } + } + } + } + + // MARK: - Stat box + + func statBox(count: Int, label: String, icon: String, color: Color) -> some View { + VStack(spacing: 6) { + Image(systemName: icon).font(.system(size: 18)).foregroundColor(color) + Text("\(count)").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0) + Text(label).font(.system(size: 12)).foregroundColor(Color.zxF04) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.zxSurface).clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1)) + } +} + +// MARK: - File size formatter + +private func formatFileSize(_ bytes: UInt64) -> String { + if bytes < 1024 { return "\(bytes) B" } + if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) } + return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) +} diff --git a/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift new file mode 100644 index 0000000..99698aa --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/MaterialReader/MaterialReaderView.swift @@ -0,0 +1,554 @@ +import SwiftUI +import QuickLook +import Combine + +// MARK: - Preview mode mapping (mirrors Rust MaterialType::preview_mode) + +func previewMode(for type: MaterialType) -> PreviewMode { + switch type { + case .markdown, .text, .image, .epub: .nativeReader + case .pdf, .word, .excel: .platformPreview + case .powerPoint: .externalOpen + case .unknown: .unsupported + } +} + +// MARK: - ViewModel + +@MainActor +final class MaterialReaderViewModel: ObservableObject { + @Published var loadingState: LoadState = .loading + @Published var blocks: [DocumentBlock] = [] + @Published var textContent: String = "" + @Published var imageMeta: ImageMeta? + @Published var stats: TextStats? + + let materialId: String + let filePath: String + let materialType: MaterialType + let mode: PreviewMode + + enum LoadState: Equatable { + case idle, loading, loaded, error(String) + } + + init(materialId: String, filePath: String, materialType: MaterialType) { + self.materialId = materialId + self.filePath = filePath + self.materialType = materialType + self.mode = previewMode(for: materialType) + print("[READER] init — materialId=\(materialId), type=\(materialType), path=\(filePath)") + } + + func load() async { + loadingState = .loading + print("[READER] load() start — materialType=\(materialType)") + + // Check file before doing anything + let fileExists = FileManager.default.fileExists(atPath: filePath) + let fileSize: Int = (try? FileManager.default.attributesOfItem(atPath: filePath)[.size] as? Int) ?? -1 + print("[READER] file exists=\(fileExists), size=\(fileSize)") + + do { + switch materialType { + case .markdown: + let t0 = CFAbsoluteTimeGetCurrent() + let content = try String(contentsOfFile: filePath, encoding: .utf8) + let t1 = CFAbsoluteTimeGetCurrent() + print("[READER] markdown read — contentLength=\(content.count), readMs=\((t1-t0)*1000)") + print("[READER] calling parseMarkdown...") + blocks = try parseMarkdown(content: content) + let t2 = CFAbsoluteTimeGetCurrent() + print("[READER] parseMarkdown done — blockCount=\(blocks.count), parseMs=\((t2-t1)*1000)") + case .text: + let t0 = CFAbsoluteTimeGetCurrent() + let content = try String(contentsOfFile: filePath, encoding: .utf8) + let t1 = CFAbsoluteTimeGetCurrent() + print("[READER] text read — contentLength=\(content.count), readMs=\((t1-t0)*1000)") + print("[READER] calling parseText...") + blocks = try parseText(content: content) + let t2 = CFAbsoluteTimeGetCurrent() + print("[READER] parseText done — blockCount=\(blocks.count), parseMs=\((t2-t1)*1000)") + print("[READER] calling readTextStats...") + stats = try? readTextStats(filePath: filePath) + print("[READER] readTextStats done — stats=\(String(describing: stats))") + case .image: + print("[READER] calling readImageMeta...") + imageMeta = try readImageMeta(filePath: filePath) + print("[READER] readImageMeta done — meta=\(String(describing: imageMeta))") + case .pdf, .word, .excel, .powerPoint, .epub, .unknown: + print("[READER] unsupported/native type — skipping Rust call") + } + loadingState = .loaded + print("[READER] load() finished successfully") + } catch let error as DocumentError { + print("[READER] DocumentError — \(error.localizedDescription), type=\(error)") + loadingState = .error(error.localizedDescription) + } catch { + print("[READER] error — \(error.localizedDescription), type=\(type(of: error))") + loadingState = .error(error.localizedDescription) + } + } +} + +// MARK: - Main View + +struct MaterialReaderView: View { + @StateObject private var vm: MaterialReaderViewModel + @Binding var showQuickLook: Bool + @Binding var showNoteSheet: Bool + @State private var scrollProgress: CGFloat = 0 + @State private var hasRestoredPosition = false + @State private var restoreBlockId: String? + + private let title: String + + // Event collector — records reading events during the session + private let collector = ReadingEventCollector.shared + private let positionStore = ReadingPositionStore.shared + + init(materialId: String, filePath: String, materialType: MaterialType, title: String = "", showQuickLook: Binding = .constant(false), showNoteSheet: Binding = .constant(false)) { + self.title = title + self._showQuickLook = showQuickLook + self._showNoteSheet = showNoteSheet + _vm = StateObject(wrappedValue: MaterialReaderViewModel( + materialId: materialId, filePath: filePath, materialType: materialType)) + } + + var body: some View { + ZStack { + Color.zxCanvas.ignoresSafeArea() + + switch vm.loadingState { + case .idle, .loading: + VStack(spacing: 12) { + ProgressView().tint(Color.zxPrimary) + Text("加载资料…").font(.system(size: 14)).foregroundColor(Color.zxF04) + } + case .error(let msg): + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxCoral) + Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04) + Button("重试") { Task { await vm.load() } } + .font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary) + .padding(.horizontal, 24).padding(.vertical, 10) + .background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10)) + } + case .loaded: + switch vm.mode { + case .nativeReader: + nativeReaderBody + case .platformPreview: + platformPreviewBody + case .externalOpen: + externalOpenBody + case .unsupported: + unsupportedBody + } + } + } + .navigationTitle(title.isEmpty ? vm.materialType.displayName : title) + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .tabBar) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: Route.aiChat(context: ChatEntryContext( + scopeType: .material, + scopeId: vm.materialId, + scopeName: title, + parentKnowledgeBaseId: nil, + createdFrom: "material_reader" + ))) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 16)) + .foregroundColor(Color.zxF05) + } + } + } + .task { await vm.load() } + .sheet(isPresented: $showQuickLook) { + QuickLookPreview(url: URL(fileURLWithPath: vm.filePath)) + } + .sheet(isPresented: $showNoteSheet) { + QuickNoteSheet( + materialId: vm.materialId, + materialName: vm.materialType.displayName, + anchor: buildAnchor() + ) + } + .onAppear { + // FIXME: collector calls Rust FFI with struct-passing (broken on ARM64 iOS) + // collector.open(materialId: vm.materialId) + hasRestoredPosition = false + } + .onDisappear { + // FIXME: collector calls Rust FFI with struct-passing (broken on ARM64 iOS) + // if let lastPos = collector.lastPosition { + // positionStore.save(materialId: vm.materialId, position: lastPos) + // } + // _ = collector.close(materialId: vm.materialId) + } + .onChange(of: vm.loadingState) { _, newState in + if newState == .loaded, !hasRestoredPosition { + restorePosition() + hasRestoredPosition = true + } + } + } + + // MARK: - Native reader (Markdown, TXT, Image) + + @ViewBuilder + var nativeReaderBody: some View { + if vm.materialType == .image, let meta = vm.imageMeta { + imageBody(meta: meta) + } else if !vm.blocks.isEmpty { + markdownBody + } else if !vm.textContent.isEmpty { + textBody + } + } + + // MARK: Markdown block list + + var markdownBody: some View { + ScrollViewReader { scrollProxy in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let s = vm.stats { + HStack(spacing: 16) { + Label("\(s.lineCount) 行", systemImage: "text.alignleft") + Label("\(s.wordCount) 词", systemImage: "character") + } + .font(.system(size: 12)).foregroundColor(Color.zxF03) + .padding(.bottom, 4) + } + ForEach(Array(vm.blocks.enumerated()), id: \.offset) { _, block in + DocumentBlockView(block: block) + .id(blockId(from: block)) + } + } + .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) + .background(GeometryReader { geo in + Color.clear.preference( + key: ScrollOffsetKey.self, + value: geo.frame(in: .named("scroll")).minY + ) + }) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetKey.self) { offset in + scrollProgress = min(1, max(0, -offset / max(contentHeightEstimate, 1))) + reportScrollPosition() + } + .onChange(of: restoreBlockId) { _, target in + if let id = target { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation { scrollProxy.scrollTo(id, anchor: .top) } + } + restoreBlockId = nil + } + } + .scrollIndicators(.hidden) + } + } + + /// Estimate total block area height for scroll progress calculation + private var contentHeightEstimate: CGFloat { + let count = max(vm.blocks.count, 1) + return CGFloat(count) * 80 + } + + /// Build a NoteAnchor from the current scroll position (for quick note). + private func buildAnchor() -> NoteAnchor? { + guard let pos = collector.lastPosition else { return nil } + return createNoteAnchor(materialId: vm.materialId, position: pos) + } + + /// Restore saved reading position on re-entry. + private func restorePosition() { + guard let saved = positionStore.load(materialId: vm.materialId) else { return } + switch saved { + case .markdown(let blockId, _): + restoreBlockId = blockId + case .text(let lineNumber, _): + let idx = Int(lineNumber) - 1 + if idx >= 0, idx < vm.blocks.count { + restoreBlockId = blockId(from: vm.blocks[idx]) + } + case .pdf, .image, .epub, .unknown: + break // PDF/image restoration needs platform-specific handling + } + } + + /// Report current scroll position to the event collector. + private func reportScrollPosition() { + guard !vm.blocks.isEmpty else { return } + + let idx = max(0, min(vm.blocks.count - 1, + Int(scrollProgress * CGFloat(vm.blocks.count)))) + let block = vm.blocks[idx] + let bId = blockId(from: block) + let sp = Float(scrollProgress) + + let pos: ReadingPosition + switch vm.materialType { + case .markdown: + pos = .markdown(blockId: bId, scrollProgress: sp) + case .text: + pos = .text(lineNumber: UInt32(idx + 1), scrollProgress: sp) + default: + pos = .markdown(blockId: bId, scrollProgress: sp) + } + collector.updatePosition(materialId: vm.materialId, position: pos) + } + + private func blockId(from block: DocumentBlock) -> String { + switch block { + case .heading(let id, _, _): return id + case .paragraph(let id, _): return id + case .list(let id, _, _): return id + case .codeBlock(let id, _, _): return id + case .quote(let id, _): return id + case .table(let id, _, _): return id + case .imageBlock(let id, _, _): return id + case .horizontalRule(let id): return id + } + } + + // MARK: Plain text fallback + + var textBody: some View { + ScrollView { + Text(vm.textContent) + .font(.system(size: 15)).foregroundColor(Color.zxF0).lineSpacing(6) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) + } + .scrollIndicators(.hidden) + } + + // MARK: Image + + func imageBody(meta: ImageMeta) -> some View { + VStack { + if let uiImage = UIImage(contentsOfFile: vm.filePath) { + Image(uiImage: uiImage).resizable().scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity).padding(20) + } + Text("\(meta.width)×\(meta.height) · \(meta.format.uppercased()) · \(formatFileSize(meta.fileSize))") + .font(.system(size: 12)).foregroundColor(Color.zxF03) + } + } + + // MARK: - Platform preview (PDF, Word, Excel) + + var platformPreviewBody: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "doc.text").font(.system(size: 48)).foregroundColor(Color.zxF03) + Text(vm.materialType.displayName).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0) + Text("使用系统预览打开此文件").font(.system(size: 14)).foregroundColor(Color.zxF04) + } + Button { showQuickLook = true } label: { + HStack(spacing: 8) { Image(systemName: "eye"); Text("打开预览") } + .font(.system(size: 14, weight: .bold)).foregroundColor(.white) + .frame(maxWidth: .infinity).frame(height: 48) + .background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg)) + }.padding(.horizontal, 40) + } + } + + // MARK: - External open (PPT) + + var externalOpenBody: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "arrow.up.forward.app").font(.system(size: 48)).foregroundColor(Color.zxF03) + Text("PowerPoint 演示文稿").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0) + Text("此格式需要外部应用打开").font(.system(size: 14)).foregroundColor(Color.zxF04) + } + if let url = URL(string: "shareddocuments://" + vm.filePath) { + ShareLink(item: url) { + HStack(spacing: 8) { Image(systemName: "square.and.arrow.up"); Text("用其他应用打开") } + .font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxPrimary) + .frame(maxWidth: .infinity).frame(height: 48) + .background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg)) + } + } + } + } + + // MARK: - Unsupported + + var unsupportedBody: some View { + VStack(spacing: 16) { + Image(systemName: "questionmark.folder").font(.system(size: 48)).foregroundColor(Color.zxF03) + Text("暂不支持此格式").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0) + Text(vm.filePath).font(.system(size: 12)).foregroundColor(Color.zxF05) + } + } +} + +// MARK: - DocumentBlock renderer (renders Rust-generated blocks via SwiftUI) + +struct DocumentBlockView: View { + let block: DocumentBlock + + var body: some View { + switch block { + case .heading(let id, let level, let text): + headingView(level: Int(level), text: text) + case .paragraph(let id, let text): + Text(text).font(.system(size: 15)).foregroundColor(Color.zxF0).lineSpacing(6) + case .list(let id, let ordered, let items): + listView(ordered: ordered, items: items) + case .codeBlock(let id, let language, let code): + codeView(language: language, code: code) + case .quote(let id, let text): + quoteView(text: text) + case .table(let id, let headers, let rows): + tableView(headers: headers, rows: rows) + case .imageBlock(let id, let src, let alt): + imageBlockView(src: src, alt: alt) + case .horizontalRule(let id): + Rectangle().fill(Color.zxBorder006).frame(height: 1) + } + } + + func headingView(level: Int, text: String) -> some View { + let sizes: [CGFloat] = [0, 24, 20, 17, 15, 14, 13] + let size = level < sizes.count ? sizes[level] : 13 + return Text(text) + .font(.system(size: size, weight: .bold)).foregroundColor(Color.zxF0) + .padding(.top, level <= 1 ? 8 : 4) + } + + func listView(ordered: Bool, items: [String]) -> some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(items.enumerated()), id: \.offset) { i, item in + HStack(alignment: .top, spacing: 8) { + Text(ordered ? "\(i + 1)." : "•") + .font(.system(size: 14, weight: ordered ? .medium : .regular)) + .foregroundColor(Color.zxF04).frame(width: 20, alignment: ordered ? .trailing : .center) + Text(item).font(.system(size: 14)).foregroundColor(Color.zxF0) + } + } + } + } + + func codeView(language: String?, code: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let lang = language { + Text(lang).font(.system(size: 11, weight: .medium)).foregroundColor(Color.zxF03) + .padding(.horizontal, 12).padding(.top, 8) + } + Text(code).font(.system(size: 13, design: .monospaced)).foregroundColor(Color.zxF0) + .padding(12).frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.zxBorder008, lineWidth: 1)) + } + + func quoteView(text: String) -> some View { + HStack(spacing: 0) { + Rectangle().fill(Color.zxPrimary.opacity(0.3)).frame(width: 3) + Text(text).font(.system(size: 14)).foregroundColor(Color.zxF04).italic().padding(.leading, 12) + } + } + + func tableView(headers: [String], rows: [[String]]) -> some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + ForEach(Array(headers.enumerated()), id: \.offset) { _, h in + Text(h).font(.system(size: 13, weight: .semibold)).foregroundColor(Color.zxF0) + .frame(maxWidth: .infinity, alignment: .leading).padding(8) + } + }.background(Color.zxFill004) + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 0) { + ForEach(Array(row.enumerated()), id: \.offset) { _, cell in + Text(cell).font(.system(size: 13)).foregroundColor(Color.zxF0) + .frame(maxWidth: .infinity, alignment: .leading).padding(8) + } + } + } + } + .background(Color.zxSurface).clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.zxBorder008, lineWidth: 1)) + } + + func imageBlockView(src: String, alt: String?) -> some View { + VStack(spacing: 6) { + if let url = URL(string: src), src.hasPrefix("http") { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): img.resizable().scaledToFit().clipShape(RoundedRectangle(cornerRadius: 10)) + case .failure: Rectangle().fill(Color.zxFill004).frame(height: 160).overlay(Image(systemName: "photo").foregroundColor(Color.zxF03)) + default: ProgressView().frame(height: 160) + } + } + } else { + Rectangle().fill(Color.zxFill004).frame(height: 120) + .overlay(VStack(spacing: 4) { Image(systemName: "photo"); Text(src).font(.system(size: 11)) }.foregroundColor(Color.zxF03)) + } + if let alt = alt { Text(alt).font(.system(size: 12)).foregroundColor(Color.zxF04) } + } + } +} + +// MARK: - MaterialType display name + +extension MaterialType { + var displayName: String { + switch self { + case .markdown: "Markdown" + case .text: "纯文本" + case .image: "图片" + case .pdf: "PDF" + case .word: "Word 文档" + case .excel: "Excel 表格" + case .powerPoint: "PowerPoint" + case .epub: "EPUB" + case .unknown: "未知格式" + } + } +} + +// MARK: - QuickLook wrapper + +struct QuickLookPreview: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> QLPreviewController { + let c = QLPreviewController() + c.dataSource = context.coordinator + return c + } + + func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) {} + func makeCoordinator() -> Coordinator { Coordinator(url: url) } + + class Coordinator: NSObject, QLPreviewControllerDataSource { + let url: URL + init(url: URL) { self.url = url } + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { url as QLPreviewItem } + } +} + +// MARK: - Scroll tracking + +private struct ScrollOffsetKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } +} + +// MARK: - Helpers + +private func formatFileSize(_ bytes: UInt64) -> String { + if bytes < 1024 { return "\(bytes) B" } + if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) } + return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) +}