更新阅读器功能与示例

This commit is contained in:
shen
2026-07-27 21:43:13 +08:00
parent 68d9363f0a
commit 9392027106
78 changed files with 8780 additions and 2084 deletions
@@ -0,0 +1,136 @@
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 "存在冲突" }
}
}