feat(ios): IOS-M1-04 AI 对话增强

- AIChatPage 新增引用来源展示(AI回答下方胶囊标签)
- 新增复制/重新生成按钮
- 右上角会话列表按钮
- SendMessageResponse 新增 citations 字段
- AIMessage 新增 citations 属性

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-05-29 19:48:46 +08:00
parent 2fcf68a64b
commit 5ff979cc97
3 changed files with 56 additions and 25 deletions

View File

@ -441,6 +441,7 @@ struct SendMessageResponse: Codable {
let tokens: Int? let tokens: Int?
let blocked: Bool? let blocked: Bool?
let message: String? let message: String?
let citations: [ChatCitation]?
} }
// MARK: - Document Import // MARK: - Document Import

View File

@ -1,7 +1,10 @@
import SwiftUI import SwiftUI
import UIKit
struct AIChatPage: View { struct AIChatPage: View {
@StateObject private var vm = AIChatViewModel() @StateObject private var vm = AIChatViewModel()
@State private var showSessions = false
@State private var sessions: [ChatSession] = []
var body: some View { var body: some View {
ZStack { ZStack {
@ -22,33 +25,58 @@ struct AIChatPage: View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
ForEach(vm.messages) { m in 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 { if vm.isSending {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple) Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple)
.frame(width: 28, height: 28) .frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle())
.background(Color(hex: "#7C6EFA", opacity: 0.15)) ZXDotLoader(color: Color.zxPurple).padding(.leading, 4); Spacer()
.clipShape(Circle())
ZXDotLoader(color: Color.zxPurple).padding(.leading, 4)
Spacer()
}.padding(.horizontal, 20) }.padding(.horizontal, 20)
} }
} }.padding(.top, 8).padding(.bottom, 100)
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
} }
.scrollIndicators(.hidden) .scrollIndicators(.hidden)
.onChange(of: vm.messages.count) { _ in .onChange(of: vm.messages.count) { _ in withAnimation { proxy.scrollTo(vm.messages.last?.id) } }
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)
.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() } .task { await vm.load() }
} }
@ -56,21 +84,16 @@ struct AIChatPage: View {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
if m.role == .ai { if m.role == .ai {
Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple) Image(systemName: "brain.head.profile").foregroundColor(Color.zxPurple)
.frame(width: 28, height: 28) .frame(width: 28, height: 28).background(Color(hex: "#7C6EFA", opacity: 0.15)).clipShape(Circle())
.background(Color(hex: "#7C6EFA", opacity: 0.15))
.clipShape(Circle())
} }
Text(m.content).zxFontScaled(size: 14) Text(m.content).zxFontScaled(size: 14)
.foregroundColor(m.role == .user ? .white : Color.zxF007) .foregroundColor(m.role == .user ? .white : Color.zxF007).padding(12)
.padding(12)
.background(m.role == .user ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill004)) .background(m.role == .user ? AnyView(ZXGradient.brandPurple) : AnyView(Color.zxFill004))
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
if m.role == .user { if m.role == .user {
Circle().frame(width: 28, height: 28) Circle().frame(width: 28, height: 28)
.foregroundColor(Color.zxPurpleBG(0.2)) .foregroundColor(Color.zxPurpleBG(0.2)).overlay(Text("").font(.system(size: 10, weight: .bold)).foregroundColor(Color.zxPurple))
.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)
} }
} }

View File

@ -5,6 +5,13 @@ struct AIMessage: Identifiable {
let id = UUID() let id = UUID()
let role: AIMessageRole let role: AIMessageRole
let content: String let content: String
let citations: [ChatCitation]?
}
struct AIMessageCitation: Identifiable {
let id: String
let title: String?
let content: String?
} }
enum AIMessageRole { enum AIMessageRole {
@ -59,7 +66,7 @@ final class AIChatViewModel: ObservableObject {
func send() { func send() {
guard canSend, let sid = sessionId 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, citations: nil))
inputText = "" inputText = ""
isSending = true isSending = true
@ -67,9 +74,9 @@ final class AIChatViewModel: ObservableObject {
do { do {
let resp = try await RagChatService.shared.sendMessage(sessionId: sid, content: text) let resp = try await RagChatService.shared.sendMessage(sessionId: sid, content: text)
if resp.blocked == true { if resp.blocked == true {
messages.append(AIMessage(role: .ai, content: resp.message ?? "内容违规,请修改后重试")) messages.append(AIMessage(role: .ai, content: resp.message ?? "内容违规,请修改后重试", citations: nil))
} else { } else {
messages.append(AIMessage(role: .ai, content: resp.content ?? "抱歉AI 暂时无法回复")) messages.append(AIMessage(role: .ai, content: resp.content ?? "抱歉AI 暂时无法回复", citations: resp.citations))
} }
} catch { } catch {
messages.append(AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)")) messages.append(AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)"))