untested: iOS change for tool call logging
This commit is contained in:
@@ -125,6 +125,9 @@ actor SybilAPIClient {
|
|||||||
case "meta":
|
case "meta":
|
||||||
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||||
await onEvent(.meta(payload))
|
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":
|
case "delta":
|
||||||
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||||
await onEvent(.delta(payload))
|
await onEvent(.delta(payload))
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ private struct MessageBubble: View {
|
|||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
|
||||||
|
private var toolCallMetadata: ToolCallMetadata? {
|
||||||
|
message.toolCallMetadata
|
||||||
|
}
|
||||||
|
|
||||||
private var isUser: Bool {
|
private var isUser: Bool {
|
||||||
message.role == .user
|
message.role == .user
|
||||||
}
|
}
|
||||||
@@ -83,6 +87,12 @@ private struct MessageBubble: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
if let toolCallMetadata {
|
||||||
|
ToolCallActivityChip(
|
||||||
|
metadata: toolCallMetadata,
|
||||||
|
fallbackContent: message.content
|
||||||
|
)
|
||||||
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if isPendingAssistant {
|
if isPendingAssistant {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -123,4 +133,58 @@ private struct MessageBubble: View {
|
|||||||
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,102 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var role: MessageRole
|
public var role: MessageRole
|
||||||
public var content: String
|
public var content: String
|
||||||
public var name: 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 {
|
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
@@ -153,12 +249,26 @@ public struct CompletionStreamDone: Codable, Sendable {
|
|||||||
public var text: String
|
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 struct StreamErrorPayload: Codable, Sendable {
|
||||||
public var message: String
|
public var message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CompletionStreamEvent: Sendable {
|
public enum CompletionStreamEvent: Sendable {
|
||||||
case meta(CompletionStreamMeta)
|
case meta(CompletionStreamMeta)
|
||||||
|
case toolCall(CompletionStreamToolCall)
|
||||||
case delta(CompletionStreamDelta)
|
case delta(CompletionStreamDelta)
|
||||||
case done(CompletionStreamDone)
|
case done(CompletionStreamDone)
|
||||||
case error(StreamErrorPayload)
|
case error(StreamErrorPayload)
|
||||||
|
|||||||
@@ -693,7 +693,9 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let requestMessages: [CompletionRequestMessage] =
|
let requestMessages: [CompletionRequestMessage] =
|
||||||
baseChat.messages.map {
|
baseChat.messages
|
||||||
|
.filter { !$0.isToolCallLog }
|
||||||
|
.map {
|
||||||
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name)
|
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name)
|
||||||
} + [CompletionRequestMessage(role: .user, content: content)]
|
} + [CompletionRequestMessage(role: .user, content: content)]
|
||||||
|
|
||||||
@@ -749,6 +751,9 @@ final class SybilViewModel {
|
|||||||
case let .meta(payload):
|
case let .meta(payload):
|
||||||
pendingChatState?.chatID = payload.chatId
|
pendingChatState?.chatID = payload.chatId
|
||||||
|
|
||||||
|
case let .toolCall(payload):
|
||||||
|
insertPendingToolCallMessage(payload)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
guard !payload.text.isEmpty else { return }
|
||||||
mutatePendingAssistantMessage { existing in
|
mutatePendingAssistantMessage { existing in
|
||||||
@@ -880,6 +885,51 @@ final class SybilViewModel {
|
|||||||
pendingChatState = pending
|
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? {
|
private var currentChatID: String? {
|
||||||
if draftKind == .chat {
|
if draftKind == .chat {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user