更新阅读器功能与示例
This commit is contained in:
@@ -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 "存在冲突" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user