Files
hapi/ios/Hapi/Features/Chat/Blocks/QuestionAnswerDraft.swift
T

247 lines
9.5 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
/// Presentation adapter only. The existing parsers and dialect-specific wire
/// values stay authoritative; display labels must never become answer values.
struct QuestionAnswerForm: Equatable {
enum Field: Equatable {
case ask(AskQuestion, key: String, stableOptionIDs: Bool)
case request(RequestUserInputQuestion)
var key: String {
switch self {
case .ask(_, let key, _): key
case .request(let question): question.id
}
}
var header: String? {
switch self {
case .ask(let question, _, _): question.header
case .request(let question): question.header
}
}
var question: String {
switch self {
case .ask(let question, _, _): question.question
case .request(let question): question.question
}
}
var options: [AskOption] {
switch self {
case .ask(let question, _, _): question.options
case .request(let question): question.options
}
}
var multiple: Bool {
switch self {
case .ask(let question, _, _): question.multiSelect
case .request(let question): question.multiple
}
}
var required: Bool {
switch self {
case .ask: true
case .request(let question): question.required
}
}
var allowsCustomAnswer: Bool {
if case .ask = self { return true }
return false
}
var prefill: String {
if case .request(let question) = self { return question.prefill ?? "" }
return ""
}
var placeholder: String? {
if case .request(let question) = self { return question.placeholder }
return nil
}
func values(selected: Set<Int>, text: String) -> [String] {
let picked = options.indices.filter(selected.contains).map { index in
let option = options[index]
if case .ask(_, _, true) = self { return option.id ?? option.label }
return option.label
}
if case .request = self {
return requestUserInputAnswerValues(selected: picked, note: text)
}
let custom = text.trimmingCharacters(in: .whitespacesAndNewlines)
return picked + (custom.isEmpty ? [] : [custom])
}
func isAnswered(selected: Set<Int>, text: String) -> Bool {
if case .request(let question) = self {
return isRequestUserInputAnswered(
question, selected: options.indices.filter(selected.contains).map { options[$0].label }, note: text
)
}
return !values(selected: selected, text: text).isEmpty
}
}
let fields: [Field]
let nestedAnswers: Bool
init(tool: ChatToolCall) {
let name = toolPresentationName(tool.name)
nestedAnswers = isRequestUserInputToolName(name)
if nestedAnswers {
// One answer slot per stable ID; malformed duplicates must not
// create two steps that silently overwrite one another on submit.
var seen: Set<String> = []
fields = parseRequestUserInputQuestions(tool.input)
.filter { seen.insert($0.id).inserted }.map(Field.request)
} else {
let cursor = isCursorAskQuestionToolName(name)
let parsed = parseAskUserQuestions(tool.input, cursorDialect: cursor)
let questions = parsed.isEmpty
? [AskQuestion(id: nil, header: nil, question: "", options: [], multiSelect: false)] : parsed
var seen: Set<String> = []
fields = questions.enumerated().compactMap { index, question in
let key = question.answerKey(index: index, useStableIds: cursor)
guard seen.insert(key).inserted else { return nil }
return .ask(question, key: key, stableOptionIDs: cursor)
}
}
}
}
/// One request-owned value, retained outside recycled transcript cells. Only
/// explicit user actions navigate; re-rendering or restoring a draft cannot.
struct QuestionAnswerDraft: Equatable {
static let storageField = "question.answerDraft"
private(set) var page = 0
private(set) var selections: [Int: Set<Int>] = [:]
private(set) var texts: [Int: String] = [:]
private(set) var expanded: [Int: Bool] = [:]
func currentPage(in form: QuestionAnswerForm) -> Int {
min(page, max(0, form.fields.count - 1))
}
func text(at index: Int, in form: QuestionAnswerForm) -> String {
texts[index] ?? form.fields[index].prefill
}
func showsText(at index: Int, in form: QuestionAnswerForm) -> Bool {
form.fields[index].options.isEmpty || (expanded[index] ?? !text(at: index, in: form).isEmpty)
}
func isAnswered(at index: Int, in form: QuestionAnswerForm) -> Bool {
form.fields[index].isAnswered(selected: selections[index] ?? [], text: text(at: index, in: form))
}
mutating func toggleText(at index: Int, in form: QuestionAnswerForm) {
expanded[index] = !showsText(at: index, in: form)
}
mutating func setText(_ text: String, at index: Int, in form: QuestionAnswerForm) {
guard form.fields.indices.contains(index) else { return }
texts[index] = text
let field = form.fields[index]
if field.allowsCustomAnswer && !field.multiple && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
selections[index] = []
}
}
mutating func select(_ option: Int, at index: Int, in form: QuestionAnswerForm) {
guard index == currentPage(in: form), form.fields.indices.contains(index),
form.fields[index].options.indices.contains(option) else { return }
let field = form.fields[index]
if field.multiple {
var selected = selections[index] ?? []
if selected.contains(option) { selected.remove(option) }
else { selected.insert(option) }
selections[index] = selected
} else {
selections[index] = [option]
if field.allowsCustomAnswer { texts[index] = "" }
next(in: form)
}
}
mutating func previous(in form: QuestionAnswerForm) {
page = max(0, currentPage(in: form) - 1)
}
mutating func next(in form: QuestionAnswerForm) {
let index = currentPage(in: form)
guard form.fields.indices.contains(index), isAnswered(at: index, in: form) else { return }
page = min(index + 1, form.fields.count - 1)
}
func submission(in form: QuestionAnswerForm) -> PermissionAction? {
guard !form.fields.isEmpty, form.fields.indices.allSatisfy({ isAnswered(at: $0, in: form) }) else { return nil }
var answers: [String: [String]] = [:]
for (index, field) in form.fields.enumerated() {
answers[field.key] = field.values(selected: selections[index] ?? [], text: text(at: index, in: form))
}
return form.nestedAnswers ? .nestedAnswers(answers) : .flatAnswers(answers)
}
}
struct QuestionOptionTitle: Equatable {
let text: String
let recommended: Bool
init(_ original: String) {
let suffix = " (Recommended)"
let stripped = original.hasSuffix(suffix)
? String(original.dropLast(suffix.count)).trimmingCharacters(in: .whitespacesAndNewlines) : ""
recommended = !stripped.isEmpty
text = recommended ? stripped : original
}
}
enum QuestionCardState: Equatable {
case answering, submitting, answered, handled, canceled, failed, waiting
var title: String {
switch self {
case .answered: String(localized: "Answered")
case .handled: String(localized: "Question handled")
case .canceled: String(localized: "Question canceled")
case .failed: String(localized: "Question failed")
case .waiting, .answering: String(localized: "Awaiting response")
case .submitting: String(localized: "Submitting answer…")
}
}
var icon: String {
switch self {
case .answered: "checkmark.circle.fill"
case .handled: "checkmark.circle"
case .canceled: "xmark.circle"
case .failed: "exclamationmark.circle"
default: "questionmark.circle"
}
}
init(tool: ChatToolCall, details: QuestionToolDetails, override: PermissionRowOverride?) {
if tool.state == .error { self = .failed }
else if tool.permission?.status == .denied || tool.permission?.status == .canceled { self = .canceled }
else if details.hasAnswers { self = .answered }
else if override == .alreadyHandled { self = .handled }
else if tool.permission?.status == .pending { self = override == .resolving ? .submitting : .answering }
else if tool.permission != nil || tool.state == .completed { self = .handled }
else { self = .waiting }
}
}
func questionAnswerSummary(_ question: QuestionDetail) -> String? {
let choices = question.options.filter(\.selected).map { QuestionOptionTitle($0.label).text }
let values = choices + question.otherAnswers + (question.note.map { [$0.isEmpty ? String(localized: "(empty)") : $0] } ?? [])
guard !values.isEmpty else { return nil }
return chatTruncate(values.joined(separator: " · ").split(whereSeparator: \.isWhitespace).joined(separator: " "), 240)
}