From 5ff979cc970861829c6ebd54fe6c90bf787358b4 Mon Sep 17 00:00:00 2001 From: wangdl Date: Fri, 29 May 2026 19:48:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20IOS-M1-04=20AI=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AIChatPage 新增引用来源展示(AI回答下方胶囊标签) - 新增复制/重新生成按钮 - 右上角会话列表按钮 - SendMessageResponse 新增 citations 字段 - AIMessage 新增 citations 属性 Co-Authored-By: Claude Opus 4.7 --- .../AIStudyApp/Core/Models/APIModels.swift | 1 + .../AIStudyApp/Features/AI/AIChatPage.swift | 67 +++++++++++++------ .../Features/AI/AIChatViewModel.swift | 13 +++- 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift index 0f75101..9e48aa7 100644 --- a/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift +++ b/AIStudyApp/AIStudyApp/Core/Models/APIModels.swift @@ -441,6 +441,7 @@ struct SendMessageResponse: Codable { let tokens: Int? let blocked: Bool? let message: String? + let citations: [ChatCitation]? } // MARK: - Document Import diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift index bdfba37..f63af47 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatPage.swift @@ -1,7 +1,10 @@ import SwiftUI +import UIKit struct AIChatPage: View { @StateObject private var vm = AIChatViewModel() + @State private var showSessions = false + @State private var sessions: [ChatSession] = [] var body: some View { ZStack { @@ -22,33 +25,58 @@ struct AIChatPage: View { ScrollView { VStack(spacing: 16) { ForEach(vm.messages) { m in - chatBubble(m).id(m.id) + 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.text").font(.system(size: 9)).foregroundColor(Color.zxF04) + Text(c.content?.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: 11)).foregroundColor(Color.zxF04) + } + Button { Task { vm.send() } } label: { + Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 11)).foregroundColor(Color.zxF04) + } + }.padding(.leading, 36) + } + }.id(m.id).padding(.horizontal, 20) } 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() + .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(.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 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) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { Task { sessions = (try? await RagChatService.shared.listSessions()) ?? []; showSessions = true } } label: { + Image(systemName: "list.bullet.rectangle").font(.system(size: 16)).foregroundColor(Color.zxF05) + } + } + } .task { await vm.load() } } @@ -56,21 +84,16 @@ struct AIChatPage: View { HStack(alignment: .top, spacing: 8) { if m.role == .ai { Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple) - .frame(width: 28, height: 28) - .background(Color(hex: "#7C6EFA", opacity: 0.15)) - .clipShape(Circle()) + .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) + .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)) + .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) + }.frame(maxWidth: .infinity, alignment: m.role == .user ? .trailing : .leading) } } diff --git a/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift b/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift index ec90a9c..b8f2b9f 100644 --- a/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift +++ b/AIStudyApp/AIStudyApp/Features/AI/AIChatViewModel.swift @@ -5,6 +5,13 @@ struct AIMessage: Identifiable { let id = UUID() let role: AIMessageRole let content: String + let citations: [ChatCitation]? +} + +struct AIMessageCitation: Identifiable { + let id: String + let title: String? + let content: String? } enum AIMessageRole { @@ -59,7 +66,7 @@ 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)) + messages.append(AIMessage(role: .user, content: text, citations: nil)) inputText = "" isSending = true @@ -67,9 +74,9 @@ final class AIChatViewModel: ObservableObject { do { let resp = try await RagChatService.shared.sendMessage(sessionId: sid, content: text) if resp.blocked == true { - messages.append(AIMessage(role: .ai, content: resp.message ?? "内容违规,请修改后重试")) + messages.append(AIMessage(role: .ai, content: resp.message ?? "内容违规,请修改后重试", citations: nil)) } else { - messages.append(AIMessage(role: .ai, content: resp.content ?? "抱歉,AI 暂时无法回复")) + messages.append(AIMessage(role: .ai, content: resp.content ?? "抱歉,AI 暂时无法回复", citations: resp.citations)) } } catch { messages.append(AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)"))