diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 0fd57ff..6225b7c 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -392,6 +392,52 @@ struct BatchDeleteResponse: Codable { let count: Int? } +// MARK: - RAG Chat + +struct ChatSession: Codable, Identifiable { + let id: String + let userId: String? + let knowledgeBaseId: String? + let title: String? + let createdAt: String? + let updatedAt: String? +} + +struct ChatMessage: Codable, Identifiable { + let id: String + let sessionId: String? + let role: String + let content: String + let tokens: Int? + let createdAt: String? + let citations: [ChatCitation]? +} + +struct ChatCitation: Codable, Identifiable { + let id: String + let chunkId: String? + let content: String? + let score: Double? +} + +struct CreateSessionRequest: Codable { + let knowledgeBaseId: String + let title: String? +} + +struct SendMessageRequest: Codable { + let content: String +} + +struct SendMessageResponse: Codable { + let id: String? + let role: String? + let content: String? + let tokens: Int? + let blocked: Bool? + let message: String? +} + // MARK: - File Upload (COS presigned URL flow) struct FileUploadUrlRequest: Codable { diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index d3dd331..71273ac 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -273,6 +273,36 @@ class FeedbackService { } } +// MARK: - RAG Chat + +@MainActor +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) + 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 getMessages(sessionId: String) async throws -> [ChatMessage] { + return try await client.request("/rag-chat/sessions/\(sessionId)/messages") + } + + func sendMessage(sessionId: String, content: String) async throws -> SendMessageResponse { + let body = SendMessageRequest(content: content) + return try await client.request("/rag-chat/sessions/\(sessionId)/messages", method: "POST", body: body) + } + + func deleteSession(id: String) async throws { + let _: GenericSuccessResponse = try await client.request("/rag-chat/sessions/\(id)", method: "DELETE") + } +} + // MARK: - Notifications @MainActor diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift index 4cbc91e..bdfba37 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift @@ -7,39 +7,49 @@ struct AIChatPage: View { ZStack { Color.zxBg0.ignoresSafeArea() VStack(spacing: 0) { - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 16) { - ForEach(vm.messages) { m in - chatBubble(m) - .id(m.id) - } - if vm.isSending { - HStack(spacing: 8) { - Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple) - .frame(width: 28, height: 28) - .background(Color(hex: "#7C6EFA", opacity: 0.15)) - .clipShape(Circle()) - ZXDotLoader(color: Color.zxPurple) - .padding(.leading, 4) - Spacer() + if vm.isCreatingSession { + VStack(spacing: 12) { + ProgressView().tint(Color.zxPurple) + Text("正在准备 AI 助手...").font(.system(size: 13)).foregroundColor(Color.zxF04) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = vm.sessionError { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle").font(.system(size: 36)).foregroundColor(Color.zxF04) + Text(error).font(.system(size: 14)).foregroundColor(Color.zxF04) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 16) { + ForEach(vm.messages) { m in + chatBubble(m).id(m.id) + } + if vm.isSending { + HStack(spacing: 8) { + Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple) + .frame(width: 28, height: 28) + .background(Color(hex: "#7C6EFA", opacity: 0.15)) + .clipShape(Circle()) + ZXDotLoader(color: Color.zxPurple).padding(.leading, 4) + Spacer() + }.padding(.horizontal, 20) } - .padding(.horizontal, 20) } + .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) + } + .scrollIndicators(.hidden) + .onChange(of: vm.messages.count) { _ in + withAnimation { proxy.scrollTo(vm.messages.last?.id) } } - .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) - } - .scrollIndicators(.hidden) - .onChange(of: vm.messages.count) { _ in - withAnimation { proxy.scrollTo(vm.messages.last?.id) } } + ZXAIInputBar(text: $vm.inputText, onSend: { vm.send() }) + .padding(.horizontal, 20).padding(.bottom, 34) } - ZXAIInputBar(text: $vm.inputText, onSend: { vm.send() }) - .padding(.horizontal, 20).padding(.bottom, 34) } } .navigationBarTitleDisplayMode(.inline).animatedTabBarHide() .toolbarBackground(.hidden, for: .navigationBar) + .task { await vm.load() } } private func chatBubble(_ m: AIMessage) -> some View { diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift index d2f41dd..ec90a9c 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift @@ -13,30 +13,72 @@ enum AIMessageRole { @MainActor final class AIChatViewModel: ObservableObject { - @Published var messages: [AIMessage] = [ - AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手。") - ] + @Published var messages: [AIMessage] = [] @Published var inputText = "" @Published var isSending = false + @Published var isCreatingSession = false + @Published var sessionError: String? + + private var sessionId: String? + private var knowledgeBaseId: String? var canSend: Bool { - !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending + !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending && sessionId != nil + } + + var isReady: Bool { sessionId != nil } + + 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 + do { + let session = try await RagChatService.shared.createSession(knowledgeBaseId: kbId, title: nil) + sessionId = session.id + messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于你的知识库回答问题。")] + isCreatingSession = false + } catch { + sessionError = "创建对话失败" + isCreatingSession = false + } } func send() { - guard canSend else { return } + guard canSend, let sid = sessionId else { return } let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) messages.append(AIMessage(role: .user, content: text)) inputText = "" isSending = true Task { - try? await Task.sleep(nanoseconds: 1_200_000_000) - messages.append(AIMessage( - role: .ai, - content: "好的,我理解你的问题。需要我帮你制定学习计划吗?" - )) + do { + let resp = try await RagChatService.shared.sendMessage(sessionId: sid, content: text) + if resp.blocked == true { + messages.append(AIMessage(role: .ai, content: resp.message ?? "内容违规,请修改后重试")) + } else { + messages.append(AIMessage(role: .ai, content: resp.content ?? "抱歉,AI 暂时无法回复")) + } + } catch { + messages.append(AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)")) + } isSending = false } } + + func setKnowledgeBase(_ id: String) { + knowledgeBaseId = id + } }