Show in-progress tool calls
This commit is contained in:
@@ -138,6 +138,12 @@ private struct MessageBubble: View {
|
||||
}
|
||||
|
||||
private struct ToolCallActivityChip: View {
|
||||
enum VisualState {
|
||||
case initiated
|
||||
case completed
|
||||
case failed
|
||||
}
|
||||
|
||||
var metadata: ToolCallMetadata
|
||||
var fallbackContent: String
|
||||
var createdAt: Date
|
||||
@@ -184,11 +190,22 @@ private struct ToolCallActivityChip: View {
|
||||
}
|
||||
|
||||
private var isFailed: Bool {
|
||||
(metadata.status ?? "").lowercased() == "failed"
|
||||
visualState == .failed
|
||||
}
|
||||
|
||||
private var visualState: VisualState {
|
||||
switch (metadata.status ?? "").lowercased() {
|
||||
case "failed":
|
||||
return .failed
|
||||
case "initiated":
|
||||
return .initiated
|
||||
default:
|
||||
return .completed
|
||||
}
|
||||
}
|
||||
|
||||
private var detailLabel: String {
|
||||
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
|
||||
var pieces: [String] = [stateLabel]
|
||||
if let durationMs = metadata.durationMs, durationMs > 0 {
|
||||
pieces.append("\(durationMs) ms")
|
||||
}
|
||||
@@ -200,14 +217,14 @@ private struct ToolCallActivityChip: View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
||||
.fill(iconColor.opacity(0.13))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
@@ -221,7 +238,7 @@ private struct ToolCallActivityChip: View {
|
||||
HStack(spacing: 6) {
|
||||
Text(toolLabel)
|
||||
.font(.sybil(.caption2, weight: .semibold))
|
||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
|
||||
.foregroundStyle(iconColor.opacity(0.90))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(detailLabel)
|
||||
@@ -236,12 +253,45 @@ private struct ToolCallActivityChip: View {
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
||||
.fill(backgroundGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
||||
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: 520, alignment: .leading)
|
||||
}
|
||||
|
||||
private var stateLabel: String {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return "Failed"
|
||||
case .initiated:
|
||||
return "Running"
|
||||
case .completed:
|
||||
return "Completed"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return SybilTheme.danger
|
||||
case .initiated:
|
||||
return SybilTheme.warning
|
||||
case .completed:
|
||||
return SybilTheme.accent
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundGradient: LinearGradient {
|
||||
switch visualState {
|
||||
case .failed:
|
||||
return SybilTheme.failedToolCallGradient
|
||||
case .initiated:
|
||||
return SybilTheme.runningToolCallGradient
|
||||
case .completed:
|
||||
return SybilTheme.toolCallGradient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,8 +514,8 @@ public struct CompletionStreamToolCall: Codable, Sendable {
|
||||
public var summary: String
|
||||
public var args: [String: JSONValue]
|
||||
public var startedAt: String
|
||||
public var completedAt: String
|
||||
public var durationMs: Int
|
||||
public var completedAt: String?
|
||||
public var durationMs: Int?
|
||||
public var error: String?
|
||||
public var resultPreview: String?
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ enum SybilTheme {
|
||||
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
||||
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
||||
static let warning = Color(red: 0.95, green: 0.69, blue: 0.25)
|
||||
|
||||
@MainActor static func applySystemAppearance() {
|
||||
let navAppearance = UINavigationBarAppearance()
|
||||
@@ -186,6 +187,17 @@ enum SybilTheme {
|
||||
)
|
||||
}
|
||||
|
||||
static var runningToolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72),
|
||||
Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
static var failedToolCallGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
|
||||
@@ -1186,7 +1186,7 @@ final class SybilViewModel {
|
||||
break
|
||||
|
||||
case let .toolCall(payload):
|
||||
insertQuickQuestionToolCallMessage(payload)
|
||||
upsertQuickQuestionToolCallMessage(payload)
|
||||
|
||||
case let .delta(payload):
|
||||
guard !payload.text.isEmpty else { return }
|
||||
@@ -2006,7 +2006,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
case let .toolCall(payload):
|
||||
insertPendingToolCallMessage(payload, chatID: chatID)
|
||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||
|
||||
case let .delta(payload):
|
||||
guard !payload.text.isEmpty else { return }
|
||||
@@ -2222,12 +2222,14 @@ final class SybilViewModel {
|
||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||
}
|
||||
|
||||
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID] else {
|
||||
return
|
||||
}
|
||||
|
||||
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
||||
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||
pendingChatStates[chatID] = pending
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2242,8 +2244,9 @@ final class SybilViewModel {
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
||||
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2255,8 +2258,8 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
|
||||
let metadata: JSONValue = .object([
|
||||
private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message {
|
||||
var metadataObject: [String: JSONValue] = [
|
||||
"kind": .string("tool_call"),
|
||||
"toolCallId": .string(payload.toolCallId),
|
||||
"toolName": .string(payload.name),
|
||||
@@ -2264,19 +2267,26 @@ final class SybilViewModel {
|
||||
"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
|
||||
])
|
||||
]
|
||||
|
||||
if let completedAt = payload.completedAt {
|
||||
metadataObject["completedAt"] = .string(completedAt)
|
||||
}
|
||||
if let durationMs = payload.durationMs {
|
||||
metadataObject["durationMs"] = .number(Double(durationMs))
|
||||
}
|
||||
|
||||
let metadata: JSONValue = .object(metadataObject)
|
||||
|
||||
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Ran tool '\(payload.name)'."
|
||||
: payload.summary
|
||||
|
||||
return Message(
|
||||
id: "temp-tool-\(payload.toolCallId)",
|
||||
createdAt: Date(),
|
||||
id: id ?? "temp-tool-\(payload.toolCallId)",
|
||||
createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(),
|
||||
role: .tool,
|
||||
content: summary,
|
||||
name: payload.name,
|
||||
@@ -2284,6 +2294,19 @@ final class SybilViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
private func toolCallDate(from value: String?) -> Date? {
|
||||
guard let value else { return nil }
|
||||
let fractionalFormatter = ISO8601DateFormatter()
|
||||
fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractionalFormatter.date(from: value) {
|
||||
return date
|
||||
}
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: value)
|
||||
}
|
||||
|
||||
private var currentChatID: String? {
|
||||
if draftKind == .chat {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user