untested: iOS change for tool call logging
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user