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? 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 "存在冲突" } } }