feat(ios): IOS-M0-01 RAG Chat 接入真实 API

- 新增 RagChatService: createSession/listSessions/getMessages/sendMessage/deleteSession
- 新增 ChatSession/ChatMessage/SendMessageResponse 等数据模型
- AIChatViewModel 重写:移除 mock,调用真实 /rag-chat API
- 自动选择知识库创建对话 session
- AIChatPage 增加加载态(创建 session 中)和错误态(无知识库)
- send() → POST /rag-chat/sessions/:id/messages 返回真实 AI 回复

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-28 20:00:07 +08:00
parent 481f513b11
commit 444f1842d0
4 changed files with 162 additions and 34 deletions

View File

@ -392,6 +392,52 @@ struct BatchDeleteResponse: Codable {
let count: Int? 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) // MARK: - File Upload (COS presigned URL flow)
struct FileUploadUrlRequest: Codable { struct FileUploadUrlRequest: Codable {

View File

@ -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 // MARK: - Notifications
@MainActor @MainActor

View File

@ -7,39 +7,49 @@ struct AIChatPage: View {
ZStack { ZStack {
Color.zxBg0.ignoresSafeArea() Color.zxBg0.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollViewReader { proxy in if vm.isCreatingSession {
ScrollView { VStack(spacing: 12) {
VStack(spacing: 16) { ProgressView().tint(Color.zxPurple)
ForEach(vm.messages) { m in Text("正在准备 AI 助手...").font(.system(size: 13)).foregroundColor(Color.zxF04)
chatBubble(m) }.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(m.id) } else if let error = vm.sessionError {
} VStack(spacing: 16) {
if vm.isSending { Image(systemName: "exclamationmark.triangle").font(.system(size: 36)).foregroundColor(Color.zxF04)
HStack(spacing: 8) { Text(error).font(.system(size: 14)).foregroundColor(Color.zxF04)
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple) }.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(width: 28, height: 28) } else {
.background(Color(hex: "#7C6EFA", opacity: 0.15)) ScrollViewReader { proxy in
.clipShape(Circle()) ScrollView {
ZXDotLoader(color: Color.zxPurple) VStack(spacing: 16) {
.padding(.leading, 4) ForEach(vm.messages) { m in
Spacer() 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() .navigationBarTitleDisplayMode(.inline).animatedTabBarHide()
.toolbarBackground(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar)
.task { await vm.load() }
} }
private func chatBubble(_ m: AIMessage) -> some View { private func chatBubble(_ m: AIMessage) -> some View {

View File

@ -13,30 +13,72 @@ enum AIMessageRole {
@MainActor @MainActor
final class AIChatViewModel: ObservableObject { final class AIChatViewModel: ObservableObject {
@Published var messages: [AIMessage] = [ @Published var messages: [AIMessage] = []
AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手。")
]
@Published var inputText = "" @Published var inputText = ""
@Published var isSending = false @Published var isSending = false
@Published var isCreatingSession = false
@Published var sessionError: String?
private var sessionId: String?
private var knowledgeBaseId: String?
var canSend: Bool { 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() { func send() {
guard canSend else { return } guard canSend, let sid = sessionId else { return }
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
messages.append(AIMessage(role: .user, content: text)) messages.append(AIMessage(role: .user, content: text))
inputText = "" inputText = ""
isSending = true isSending = true
Task { Task {
try? await Task.sleep(nanoseconds: 1_200_000_000) do {
messages.append(AIMessage( let resp = try await RagChatService.shared.sendMessage(sessionId: sid, content: text)
role: .ai, if resp.blocked == true {
content: "好的,我理解你的问题。需要我帮你制定学习计划吗?" 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 isSending = false
} }
} }
func setKnowledgeBase(_ id: String) {
knowledgeBaseId = id
}
} }