Files
ReadViewSDK/Sources/RDAIReaderView/UI/RDAIReaderAssistantViewController.swift
T
2026-07-27 21:43:13 +08:00

137 lines
9.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
/// A reusable, local-first assistant sheet. Hosts supply the read scope so the
/// UI cannot silently expand answers beyond the reader's spoiler boundary.
@MainActor
public final class RDAIReaderAssistantViewController: UIViewController {
private let service: RDAIReaderService
private let scope: RDAIReadScope
private let output = UITextView()
private let citationsStack = UIStackView()
private let questionField = UITextField()
private let activity = UIActivityIndicatorView(style: .medium)
private var citations: [RDAICitation] = []
private var indexingTask: Task<Void, Never>?
private struct Presentation {
let text: String
let citations: [RDAICitation]
}
public init(service: RDAIReaderService, scope: RDAIReadScope) {
self.service = service
self.scope = scope
super.init(nibName: nil, bundle: nil)
title = "阅读助手"
}
required init?(coder: NSCoder) { nil }
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let closeButton = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(close))
let cancelButton = UIBarButtonItem(title: "取消索引", style: .plain, target: self, action: #selector(cancelIndexing))
navigationItem.rightBarButtonItems = [closeButton, cancelButton]
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "清除数据", style: .plain, target: self, action: #selector(clearAIData))
let summary = button("摘要", action: #selector(showSummary))
let characters = button("人物", action: #selector(showCharacters))
let relationships = button("关系", action: #selector(showRelationships))
questionField.placeholder = "向已读内容提问"
questionField.borderStyle = .roundedRect
questionField.returnKeyType = .send
questionField.delegate = self
output.isEditable = false
output.font = .preferredFont(forTextStyle: .body)
output.adjustsFontForContentSizeCategory = true
output.accessibilityIdentifier = "rdai.assistant.output"
let actions = UIStackView(arrangedSubviews: [summary, characters, relationships])
actions.axis = .horizontal; actions.distribution = .fillEqually; actions.spacing = 8
citationsStack.axis = .vertical; citationsStack.spacing = 6
let stack = UIStackView(arrangedSubviews: [actions, questionField, output, citationsStack])
stack.axis = .vertical; stack.spacing = 12
view.addSubview(stack); view.addSubview(activity)
stack.translatesAutoresizingMaskIntoConstraints = false; activity.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), output.heightAnchor.constraint(greaterThanOrEqualToConstant: 180), activity.centerXAnchor.constraint(equalTo: view.centerXAnchor), activity.centerYAnchor.constraint(equalTo: view.centerYAnchor)])
startIndexing()
}
deinit { indexingTask?.cancel() }
@objc private func close() { indexingTask?.cancel(); service.resumeIndexing(); service.clearCitationHighlight(); dismiss(animated: true) }
@objc private func cancelIndexing() { indexingTask?.cancel(); service.resumeIndexing(); output.text = "已取消本地索引。"; activity.stopAnimating() }
@objc private func clearAIData() {
let alert = UIAlertController(title: "清除 AI 数据", message: "将删除本书的本地索引、缓存和生成结果,不会删除原书或用户标注。", preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "清除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.service.removeIndex(); self.service.clearCitationHighlight(); self.output.text = "已清除本地 AI 数据。"; self.renderCitations([]) }
})
present(alert, animated: true)
}
private func startIndexing() {
indexingTask?.cancel()
indexingTask = Task { [weak self] in
guard let self else { return }
await self.prepareIndex()
}
}
private func prepareIndex() async {
activity.startAnimating()
output.text = "正在建立本地索引…"
let updatesTask = Task { [weak self] in
guard let self else { return }
for await state in service.stateUpdates() {
guard !Task.isCancelled else { return }
self.display(indexState: state)
}
}
defer { updatesTask.cancel(); activity.stopAnimating(); indexingTask = nil }
do {
try await service.prepareIndex(options: .init(scope: scope))
} catch is CancellationError {
output.text = "已取消本地索引。"
} catch {
output.text = "本地索引暂不可用。"
}
}
private func display(indexState: RDAIIndexState) {
switch indexState {
case .indexing(let completed, let total): output.text = "正在建立本地索引:\(completed)/\(total)"
case .paused(let completed, let total): output.text = "索引已暂停:\(completed)/\(total)"
case .ready: output.text = "本地索引已就绪。"
case .failed: output.text = "本地索引暂不可用。"
case .notStarted: break
}
}
@objc private func showSummary() { Task { await perform("正在生成摘要…") { let summary = try await self.service.summarize(scope: self.scope, length: .standard); return .init(text: summary.keyPoints.map(\.text).joined(separator: "\n\n"), citations: summary.citations) } } }
@objc private func showCharacters() { Task { await perform("正在整理人物…") { let values = await self.service.characters(scope: self.scope); return .init(text: values.map { "\($0.displayName)\($0.description)" }.joined(separator: "\n"), citations: values.flatMap(\.evidence)) } } }
@objc private func showRelationships() { Task { await perform("正在整理关系…") {
let characters = await self.service.characters(scope: self.scope)
let names = Dictionary(uniqueKeysWithValues: characters.map { ($0.id, $0.displayName) })
let values = await self.service.relationships(scope: self.scope)
guard !values.isEmpty else { return .init(text: "当前已读范围内没有足够证据建立人物关系。", citations: []) }
return .init(text: values.map { relationship in
let source = names[relationship.sourceCharacterIdentifier] ?? "未知人物"
let target = names[relationship.targetCharacterIdentifier] ?? "未知人物"
return "\(source) - \(relationship.label) - \(target)\(relationship.status.localizedDescription)"
}.joined(separator: "\n"), citations: values.flatMap(\.evidence))
} } }
private func ask() { guard let question = questionField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !question.isEmpty else { return }; Task { await perform("正在检索原文…") { let answer = try await self.service.answer(question: question, scope: self.scope); return .init(text: ([answer.text] + answer.statements.map(\.text)).joined(separator: "\n\n"), citations: answer.citations) } } }
private func perform(_ placeholder: String, operation: @escaping @MainActor () async throws -> Presentation) async { activity.startAnimating(); output.text = placeholder; renderCitations([]); defer { activity.stopAnimating() }; do { let presentation = try await operation(); output.text = presentation.text; renderCitations(presentation.citations) } catch { output.text = "暂时无法完成此请求。" } }
private func renderCitations(_ values: [RDAICitation]) { citations = values; citationsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }; for (index, citation) in values.enumerated() { var configuration = UIButton.Configuration.gray(); configuration.title = "原文:\(citation.quote.prefix(42))"; configuration.titleLineBreakMode = .byTruncatingTail; let button = UIButton(configuration: configuration); button.contentHorizontalAlignment = .leading; button.tag = index; button.addTarget(self, action: #selector(openCitation(_:)), for: .touchUpInside); citationsStack.addArrangedSubview(button) } }
@objc private func openCitation(_ sender: UIButton) { guard citations.indices.contains(sender.tag) else { return }; Task { try? await service.showCitation(citations[sender.tag]) } }
private func button(_ title: String, action: Selector) -> UIButton { var configuration = UIButton.Configuration.tinted(); configuration.title = title; let button = UIButton(configuration: configuration); button.addTarget(self, action: action, for: .touchUpInside); return button }
}
extension RDAIReaderAssistantViewController: UITextFieldDelegate { public func textFieldShouldReturn(_ textField: UITextField) -> Bool { ask(); return true } }
private extension RDAIRelationshipStatus {
var localizedDescription: String {
switch self { case .confirmed: return "已确认"; case .possible: return "可能"; case .conflicting: return "存在冲突" }
}
}