137 lines
9.4 KiB
Swift
137 lines
9.4 KiB
Swift
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 "存在冲突" }
|
||
}
|
||
}
|