mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
176 lines
6.0 KiB
Swift
176 lines
6.0 KiB
Swift
import HapiProtocol
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
/// One inspector per chat, owned outside recycled transcript cells. Its
|
|
/// identity stays fixed while its group and selected tool resolve from live data.
|
|
@MainActor @Observable
|
|
final class ToolInspectionState {
|
|
struct Selection {
|
|
let owner: String
|
|
var block: ToolCallBlock
|
|
}
|
|
|
|
struct GroupSelection {
|
|
let owner: String
|
|
var block: ToolGroupBlock
|
|
}
|
|
|
|
private(set) var tools: [String: ToolCallBlock] = [:]
|
|
private var groups: [String: ToolGroupBlock] = [:]
|
|
private var groupIDsByTool: [String: String] = [:]
|
|
private(set) var selection: Selection?
|
|
private(set) var groupSelection: GroupSelection?
|
|
private(set) var invalidation = 0
|
|
|
|
/// A group browser owns the presentation even when no detail is selected.
|
|
var owner: String? { groupSelection?.owner ?? selection?.owner }
|
|
|
|
var isGroupStale: Bool {
|
|
guard let groupSelection else { return false }
|
|
return groups[groupSelection.block.id] == nil
|
|
}
|
|
|
|
var isStale: Bool {
|
|
guard let selection else { return false }
|
|
return tools[selection.block.id] == nil
|
|
}
|
|
|
|
var siblingIDs: [String] {
|
|
guard let selection else { return [] }
|
|
if let groupSelection {
|
|
let ids = groupSelection.block.tools.map(\.id)
|
|
// Regrouping must not substitute a different tool at the same index.
|
|
return ids.contains(selection.block.id) ? ids : []
|
|
}
|
|
guard !isStale else { return [] }
|
|
return groupIDsByTool[selection.block.id].flatMap { groups[$0]?.tools.map(\.id) }
|
|
?? [selection.block.id]
|
|
}
|
|
|
|
var selectedIndex: Int? {
|
|
guard let selection else { return nil }
|
|
return siblingIDs.firstIndex(of: selection.block.id)
|
|
}
|
|
|
|
func update(_ blocks: [VisibleChatBlock]) {
|
|
var nextTools: [String: ToolCallBlock] = [:]
|
|
var nextGroups: [String: ToolGroupBlock] = [:]
|
|
var nextGroupIDsByTool: [String: String] = [:]
|
|
func collect(_ block: ChatBlock) {
|
|
guard case .toolCall(let tool) = block else { return }
|
|
nextTools[tool.id] = tool
|
|
tool.children.forEach(collect)
|
|
}
|
|
for block in blocks {
|
|
switch block {
|
|
case .block(let value): collect(value)
|
|
case .toolGroup(let group):
|
|
nextGroups[group.id] = group
|
|
for tool in group.tools {
|
|
nextGroupIDsByTool[tool.id] = group.id
|
|
collect(.toolCall(tool))
|
|
}
|
|
}
|
|
}
|
|
if tools != nextTools { tools = nextTools }
|
|
if groups != nextGroups { groups = nextGroups }
|
|
if groupIDsByTool != nextGroupIDsByTool { groupIDsByTool = nextGroupIDsByTool }
|
|
if let selected = groupSelection {
|
|
var latest = nextGroups[selected.block.id] ?? selected.block
|
|
if nextGroups[selected.block.id] == nil {
|
|
// Membership is a snapshot after regrouping, but remember each
|
|
// member's last available value when navigating back to it.
|
|
latest.tools = latest.tools.map { nextTools[$0.id] ?? $0 }
|
|
}
|
|
if latest != selected.block {
|
|
groupSelection = GroupSelection(owner: selected.owner, block: latest)
|
|
}
|
|
}
|
|
if let selected = selection, let latest = nextTools[selected.block.id], latest != selected.block {
|
|
selection = Selection(owner: selected.owner, block: latest)
|
|
}
|
|
}
|
|
|
|
func open(_ block: ToolCallBlock, owner: String) {
|
|
groupSelection = nil
|
|
selection = Selection(owner: owner, block: tools[block.id] ?? block)
|
|
}
|
|
|
|
@discardableResult
|
|
func openGroup(_ id: String, owner: String) -> Bool {
|
|
guard let group = groups[id] else { return false }
|
|
selection = nil
|
|
groupSelection = GroupSelection(owner: owner, block: group)
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func selectGroupTool(_ id: String) -> Bool {
|
|
guard let groupSelection, let block = groupTool(id) else { return false }
|
|
selection = Selection(owner: groupSelection.owner, block: block)
|
|
return true
|
|
}
|
|
|
|
func groupTool(_ id: String) -> ToolCallBlock? {
|
|
guard let snapshot = groupSelection?.block.tools.first(where: { $0.id == id }) else { return nil }
|
|
return tools[id] ?? snapshot
|
|
}
|
|
|
|
func returnToGroup() {
|
|
if groupSelection != nil { selection = nil }
|
|
}
|
|
|
|
func dismiss(owner: String) {
|
|
guard self.owner == owner else { return }
|
|
selection = nil
|
|
groupSelection = nil
|
|
}
|
|
|
|
func move(by offset: Int) {
|
|
guard let selection, let index = selectedIndex else { return }
|
|
let ids = siblingIDs
|
|
let next = index + offset
|
|
guard ids.indices.contains(next) else { return }
|
|
if groupSelection != nil {
|
|
selectGroupTool(ids[next])
|
|
return
|
|
}
|
|
guard let block = tools[ids[next]] else { return }
|
|
self.selection = Selection(owner: selection.owner, block: block)
|
|
}
|
|
|
|
func invalidate() {
|
|
selection = nil
|
|
groupSelection = nil
|
|
tools = [:]
|
|
groups = [:]
|
|
groupIDsByTool = [:]
|
|
invalidation += 1
|
|
}
|
|
}
|
|
|
|
func opensToolProcess(_ block: ToolCallBlock) -> Bool {
|
|
!block.children.isEmpty || isSubagentToolName(block.tool.name) || block.tool.name == "CodexAgent"
|
|
}
|
|
|
|
private struct OpenChatToolKey: EnvironmentKey {
|
|
static let defaultValue: (@MainActor (ToolCallBlock) -> Void)? = nil
|
|
}
|
|
|
|
private struct OpenChatToolGroupKey: EnvironmentKey {
|
|
static let defaultValue: (@MainActor (String) -> Void)? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var openChatToolGroup: (@MainActor (String) -> Void)? {
|
|
get { self[OpenChatToolGroupKey.self] }
|
|
set { self[OpenChatToolGroupKey.self] = newValue }
|
|
}
|
|
|
|
var openChatTool: (@MainActor (ToolCallBlock) -> Void)? {
|
|
get { self[OpenChatToolKey.self] }
|
|
set { self[OpenChatToolKey.self] = newValue }
|
|
}
|
|
}
|