untested: iOS change for tool call logging

This commit is contained in:
2026-03-02 16:18:10 -08:00
parent d5b06ce22a
commit 991316e692
4 changed files with 262 additions and 35 deletions

View File

@@ -125,6 +125,9 @@ actor SybilAPIClient {
case "meta":
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
await onEvent(.meta(payload))
case "tool_call":
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
await onEvent(.toolCall(payload))
case "delta":
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
await onEvent(.delta(payload))

View File

@@ -71,6 +71,10 @@ private struct MessageBubble: View {
var message: Message
var isSending: Bool
private var toolCallMetadata: ToolCallMetadata? {
message.toolCallMetadata
}
private var isUser: Bool {
message.role == .user
}
@@ -83,44 +87,104 @@ private struct MessageBubble: View {
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 8) {
if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text("Thinking…")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
} else {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTextStyle {
FontSize(15)
if let toolCallMetadata {
ToolCallActivityChip(
metadata: toolCallMetadata,
fallbackContent: message.content
)
} else {
VStack(alignment: .leading, spacing: 8) {
if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.textMuted)
Text("Thinking…")
.font(.footnote)
.foregroundStyle(SybilTheme.textMuted)
}
}
}
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 11 : 2)
.background(
Group {
if isUser {
RoundedRectangle(cornerRadius: 16)
.fill(SybilTheme.userBubble.opacity(0.86))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
)
.padding(.vertical, 2)
} else {
RoundedRectangle(cornerRadius: 0)
.fill(Color.clear)
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTextStyle {
FontSize(15)
}
}
}
)
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 11 : 2)
.background(
Group {
if isUser {
RoundedRectangle(cornerRadius: 16)
.fill(SybilTheme.userBubble.opacity(0.86))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
)
} else {
RoundedRectangle(cornerRadius: 0)
.fill(Color.clear)
}
}
)
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
}
}
}
}
private struct ToolCallActivityChip: View {
var metadata: ToolCallMetadata
var fallbackContent: String
private var summary: String {
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
return text
}
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return fallbackContent
}
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'."
}
private var iconName: String {
let name = (metadata.toolName ?? "").lowercased()
if name.contains("search") {
return "globe"
}
if name.contains("url") || name.contains("fetch") || name.contains("http") {
return "link"
}
return "wrench.and.screwdriver"
}
private var isFailed: Bool {
(metadata.status ?? "").lowercased() == "failed"
}
var body: some View {
HStack(spacing: 8) {
Image(systemName: iconName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.primary)
Text(summary)
.font(.caption)
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.95) : SybilTheme.text.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 10)
.fill((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.12))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.35), lineWidth: 1)
)
)
.frame(maxWidth: 460, alignment: .leading)
}
}

View File

@@ -46,6 +46,102 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
public var role: MessageRole
public var content: String
public var name: String?
public var metadata: JSONValue? = nil
public var toolCallMetadata: ToolCallMetadata? {
guard role == .tool,
let object = metadata?.objectValue,
object["kind"]?.stringValue == "tool_call"
else { return nil }
return ToolCallMetadata(
toolCallId: object["toolCallId"]?.stringValue,
toolName: object["toolName"]?.stringValue ?? name,
status: object["status"]?.stringValue,
summary: object["summary"]?.stringValue
)
}
public var isToolCallLog: Bool {
toolCallMetadata != nil
}
}
public struct ToolCallMetadata: Hashable, Sendable {
public var toolCallId: String?
public var toolName: String?
public var status: String?
public var summary: String?
}
public enum JSONValue: Codable, Hashable, Sendable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
return
}
if let value = try? container.decode(Bool.self) {
self = .bool(value)
return
}
if let value = try? container.decode(Double.self) {
self = .number(value)
return
}
if let value = try? container.decode(String.self) {
self = .string(value)
return
}
if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
return
}
if let value = try? container.decode([JSONValue].self) {
self = .array(value)
return
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .string(value):
try container.encode(value)
case let .number(value):
try container.encode(value)
case let .bool(value):
try container.encode(value)
case let .object(value):
try container.encode(value)
case let .array(value):
try container.encode(value)
case .null:
try container.encodeNil()
}
}
public var stringValue: String? {
if case let .string(value) = self {
return value
}
return nil
}
public var objectValue: [String: JSONValue]? {
if case let .object(value) = self {
return value
}
return nil
}
}
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
@@ -153,12 +249,26 @@ public struct CompletionStreamDone: Codable, Sendable {
public var text: String
}
public struct CompletionStreamToolCall: Codable, Sendable {
public var toolCallId: String
public var name: String
public var status: String
public var summary: String
public var args: [String: JSONValue]
public var startedAt: String
public var completedAt: String
public var durationMs: Int
public var error: String?
public var resultPreview: String?
}
public struct StreamErrorPayload: Codable, Sendable {
public var message: String
}
public enum CompletionStreamEvent: Sendable {
case meta(CompletionStreamMeta)
case toolCall(CompletionStreamToolCall)
case delta(CompletionStreamDelta)
case done(CompletionStreamDone)
case error(StreamErrorPayload)

View File

@@ -693,7 +693,9 @@ final class SybilViewModel {
}
let requestMessages: [CompletionRequestMessage] =
baseChat.messages.map {
baseChat.messages
.filter { !$0.isToolCallLog }
.map {
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name)
} + [CompletionRequestMessage(role: .user, content: content)]
@@ -749,6 +751,9 @@ final class SybilViewModel {
case let .meta(payload):
pendingChatState?.chatID = payload.chatId
case let .toolCall(payload):
insertPendingToolCallMessage(payload)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutatePendingAssistantMessage { existing in
@@ -880,6 +885,51 @@ final class SybilViewModel {
pendingChatState = pending
}
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall) {
guard var pending = pendingChatState else {
return
}
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
return
}
let metadata: JSONValue = .object([
"kind": .string("tool_call"),
"toolCallId": .string(payload.toolCallId),
"toolName": .string(payload.name),
"status": .string(payload.status),
"summary": .string(payload.summary),
"args": .object(payload.args),
"startedAt": .string(payload.startedAt),
"completedAt": .string(payload.completedAt),
"durationMs": .number(Double(payload.durationMs)),
"error": payload.error.map { .string($0) } ?? .null,
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
])
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Ran tool '\(payload.name)'."
: payload.summary
let message = Message(
id: "temp-tool-\(payload.toolCallId)",
createdAt: Date(),
role: .tool,
content: summary,
name: payload.name,
metadata: metadata
)
if let assistantIndex = pending.messages.indices.last(where: { pending.messages[$0].id.hasPrefix("temp-assistant-") }) {
pending.messages.insert(message, at: assistantIndex)
} else {
pending.messages.append(message)
}
pendingChatState = pending
}
private var currentChatID: String? {
if draftKind == .chat {
return nil