feat: M-CHAT iOS ChatScope 入口 + AI View 对接
## ChatEntryContext 模型 - ChatScopeType enum (knowledgeBase/folder/material/knowledgeItem/global) - ChatEntryContext struct + ChatScopeSnapshot - ChatSession 更新 (新增 13 个 scope 字段) - CreateSessionRequest/UpdateChatSessionRequest ## Route + Service - Route.aiChat 从 knowledgeBaseId 改为 ChatEntryContext - RagChatService.createSession 接入 open-or-create API - listSessions 支持 scope 过滤 - 新增 updateSession (PATCH) ## 6 个入口全部接入 - 知识库详情 → knowledge_base scope - 资料详情 → material scope - 资料阅读页 → material scope - 知识点详情 → knowledge_item scope - 全局入口 → global scope ## AI Chat View - open-or-create: load() 直接调 POST /sessions - 顶部 scope 指示器 (scopeLabel + scopeIcon) - 新对话按钮在当前 scope 下工作 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4ebb70c036
commit
2610bcf7f9
@ -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]?
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<SSEChunk, Error> {
|
||||
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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -232,7 +232,7 @@ struct AIHomeView: View {
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
|
||||
NavigationLink(value: Route.aiChat) {
|
||||
NavigationLink(value: Route.aiChat(context: nil)) {
|
||||
quickActionItem(
|
||||
icon: "mic",
|
||||
label: "费曼\n解释练习"
|
||||
|
||||
@ -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("<Error>"), 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("<Error>") {
|
||||
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()) }
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
@ -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<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .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))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user