diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index 5fff944..def3e1c 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -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)) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 73d0ef3..1eea7b9 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -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) + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index c382931..d5e8f8a 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -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) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 4024268..dd41827 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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