Files
hapi/ios/Hapi/Features/Chat/Blocks/ChatJSON.swift
T
weishu a4355d1837 feat(ios): read-only chat assembly (A-M2f)
Wires the merged M2 pieces into a usable pair -> list -> live chat flow:

- pbxproj: link HapiUI into the app target (UUIDs C7/C8, mirroring the
  existing HapiProtocol/HapiClient product references; only project change).
- HubSession: MessageWindowControllers registry + per-chat ChatSession
  factory with in-memory per-session SSE resume cursors and scene-phase
  forwarding to the active chat.
- ChatSession (app): session-scope SSEClient while the chat is open;
  window opened/activated before subscribing; a single consume task awaits
  every event into the window actor, giving Android-channel-equivalent
  arrival-order ingestion; session-updated and friends route through the
  shared SyncEventRouter (detail patching), session-removed clears the
  window, a gap handshake triggers full resync + detail refetch + catch-up
  tail sync; stop hands the cursor back for seamless reopen.
- ChatPipeline (HapiClient): actor running the reduction path off-main -
  queued-row filter, normalization memoized by row instance identity,
  reduce + buildVisibleChatBlocks with previousGroups-stable group ids.
- ChatModel: window state + detail agentState + machine labels -> serial
  ~100 ms-coalesced pipeline loop (first run immediate, publishes strictly
  ordered), header title cascade, loading/empty/error states, loadOlder /
  retry, last-seen stamping on every update.
- ChatView: bottom-anchored ScrollView/LazyVStack (defaultScrollAnchor +
  scrollPosition(id:)), auto-stick at bottom, new-messages pill, top
  sentinel paging with documented scroll re-anchoring, degraded banners.
- Block views: user bubble w/ attachment chips, agent markdown, collapsed
  reasoning, tool cards (knownTools.tsx-parity presentation, status chip,
  read-only permission row w/ pending banner, per-tool bodies: terminal,
  before/after edits, write content, unified-diff detection -> DiffTextView,
  checklists, read-only questions, pretty-JSON fallback, children rail),
  tool groups, centered event rows (presentation.ts port), cli output,
  generated images (authed bytes + in-memory cache + full-screen viewer),
  codex review verdict card.
- Navigation/links: session list now pushes ChatView; app-level
  \.hapiOpenURL handler (https/http -> SFSafariViewController, custom
  schemes confirm first, hapi-file:// -> M4 placeholder) + theme injection.
- Tests: ChatPipelineTests drive the runner with fixture-derived windows
  (stable ids, memo-stable recomputes, queued filter, group-id stability
  across an older-page arrival).
- README: M2f app-layer description.
2026-08-17 20:55:36 +08:00

128 lines
3.8 KiB
Swift

import Foundation
import HapiProtocol
// App-side JSONValue conveniences. HapiProtocol keeps its own accessors
// internal (they encode JS semantics for the pipeline port); these are the
// small read-only subset the chat presentation layer needs.
extension JSONValue {
var chatObject: [String: JSONValue]? {
if case .object(let value) = self { return value }
return nil
}
var chatArray: [JSONValue]? {
if case .array(let value) = self { return value }
return nil
}
var chatString: String? {
if case .string(let value) = self { return value }
return nil
}
var chatNumber: Double? {
if case .number(let value) = self { return value }
return nil
}
/// Object member lookup; nil for arrays/scalars/missing keys.
subscript(chatKey key: String) -> JSONValue? {
chatObject?[key]
}
}
/// First string value among `keys` (the app-side twin of the pipeline's
/// internal `getInputStringAny`).
func chatInputString(_ input: JSONValue?, _ keys: [String]) -> String? {
guard let object = input?.chatObject else { return nil }
for key in keys {
if let value = object[key]?.chatString {
return value
}
}
return nil
}
/// Web `truncate`: hard cap with a trailing ellipsis.
func chatTruncate(_ text: String, _ limit: Int) -> String {
guard text.count > limit else { return text }
return String(text.prefix(limit)) + "…"
}
/// Pretty JSON for generic tool inputs/results: sorted keys, 2-space indent,
/// whole numbers printed without a trailing `.0`. Display-only (never feeds
/// the normative projection).
func chatPrettyJSON(_ value: JSONValue) -> String {
var output = ""
appendJSON(value, indent: 0, into: &output)
return output
}
private func appendJSON(_ value: JSONValue, indent: Int, into output: inout String) {
let pad = String(repeating: " ", count: indent)
let childPad = String(repeating: " ", count: indent + 1)
switch value {
case .null:
output += "null"
case .bool(let flag):
output += flag ? "true" : "false"
case .number(let number):
output += formatJSONNumber(number)
case .string(let text):
output += quoteJSONString(text)
case .array(let items):
if items.isEmpty {
output += "[]"
return
}
output += "[\n"
for (index, item) in items.enumerated() {
output += childPad
appendJSON(item, indent: indent + 1, into: &output)
output += index == items.count - 1 ? "\n" : ",\n"
}
output += pad + "]"
case .object(let members):
if members.isEmpty {
output += "{}"
return
}
output += "{\n"
let keys = members.keys.sorted()
for (index, key) in keys.enumerated() {
output += childPad + quoteJSONString(key) + ": "
appendJSON(members[key] ?? .null, indent: indent + 1, into: &output)
output += index == keys.count - 1 ? "\n" : ",\n"
}
output += pad + "}"
}
}
func formatJSONNumber(_ number: Double) -> String {
if number.rounded() == number, abs(number) < 1e15 {
return String(Int64(number))
}
return String(number)
}
private func quoteJSONString(_ text: String) -> String {
var escaped = "\""
for scalar in text.unicodeScalars {
switch scalar {
case "\"": escaped += "\\\""
case "\\": escaped += "\\\\"
case "\n": escaped += "\\n"
case "\r": escaped += "\\r"
case "\t": escaped += "\\t"
default:
if scalar.value < 0x20 {
escaped += String(format: "\\u%04x", scalar.value)
} else {
escaped.unicodeScalars.append(scalar)
}
}
}
return escaped + "\""
}