mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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.
128 lines
3.8 KiB
Swift
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 + "\""
|
|
}
|