This commit is contained in:
@@ -263,20 +263,33 @@ private struct AgentTranscriptRow: View, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentMessageRow: View {
|
||||
struct AgentMessageRow: View {
|
||||
var message: Message
|
||||
var room: SybilAgentRoom
|
||||
var room: SybilAgentRoom? = nil
|
||||
var isSending = false
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation? = nil
|
||||
var isForking = false
|
||||
var onFork: (() -> Void)? = nil
|
||||
private var own: Bool { message.role == .user }
|
||||
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if !own { AgentAvatar().padding(.bottom, 23) }
|
||||
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
|
||||
ForEach(message.agentAttachments) { attachment in
|
||||
AgentAttachmentCard(attachment: attachment, room: room)
|
||||
if let room {
|
||||
ForEach(message.agentAttachments) { attachment in
|
||||
AgentAttachmentCard(attachment: attachment, room: room)
|
||||
}
|
||||
}
|
||||
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.agentAttachments.isEmpty {
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || room == nil || message.agentAttachments.isEmpty {
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: onFork,
|
||||
agentStyle: true
|
||||
)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(own ? "You" : "Sybil")
|
||||
@@ -285,7 +298,9 @@ private struct AgentMessageRow: View {
|
||||
if own && status == "queued" { Label("Queued", systemImage: "clock") }
|
||||
if own && status == "failed" {
|
||||
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
|
||||
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
|
||||
if let room {
|
||||
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
||||
|
||||
@@ -45,16 +45,27 @@ struct SybilChatTranscriptView: View {
|
||||
ForEach(renderItems) { item in
|
||||
switch item {
|
||||
case let .message(message):
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: {
|
||||
onForkResponse?(message.id)
|
||||
}
|
||||
)
|
||||
if message.isAgentAside {
|
||||
AgentMessageRow(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: { onForkResponse?(message.id) }
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: {
|
||||
onForkResponse?(message.id)
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
case let .toolGroup(id, messages):
|
||||
ToolCallStackView(
|
||||
groupID: id,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
|
||||
enum SybilInlineMention {
|
||||
private struct Fence {
|
||||
var marker: Character
|
||||
var length: Int
|
||||
}
|
||||
|
||||
static func hasExplicitSybilMention(in text: String) -> Bool {
|
||||
let lines = text.components(separatedBy: .newlines)
|
||||
var fence: Fence?
|
||||
var inBlockquote = false
|
||||
var paragraph: [String] = []
|
||||
|
||||
func paragraphHasMention() -> Bool {
|
||||
hasMentionOutsideInlineCode(in: paragraph.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
func isBlank(_ line: String) -> Bool {
|
||||
line.allSatisfy(\.isWhitespace)
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
if let currentFence = fence {
|
||||
if let closingFence = fenceMarker(in: line, opening: false),
|
||||
closingFence.marker == currentFence.marker,
|
||||
closingFence.length >= currentFence.length {
|
||||
fence = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if let openingFence = fenceMarker(in: line, opening: true) {
|
||||
if paragraphHasMention() { return true }
|
||||
paragraph.removeAll(keepingCapacity: true)
|
||||
fence = openingFence
|
||||
continue
|
||||
}
|
||||
|
||||
if isBlank(line) {
|
||||
if paragraphHasMention() { return true }
|
||||
paragraph.removeAll(keepingCapacity: true)
|
||||
inBlockquote = false
|
||||
continue
|
||||
}
|
||||
|
||||
if isBlockquoteLine(line) {
|
||||
if paragraphHasMention() { return true }
|
||||
paragraph.removeAll(keepingCapacity: true)
|
||||
inBlockquote = true
|
||||
continue
|
||||
}
|
||||
|
||||
if inBlockquote || isIndentedCodeLine(line) {
|
||||
if !paragraph.isEmpty {
|
||||
if paragraphHasMention() { return true }
|
||||
paragraph.removeAll(keepingCapacity: true)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
paragraph.append(line)
|
||||
}
|
||||
|
||||
return paragraphHasMention()
|
||||
}
|
||||
|
||||
private static func fenceMarker(in line: String, opening: Bool) -> Fence? {
|
||||
let characters = Array(line)
|
||||
var index = 0
|
||||
while index < characters.count, characters[index] == " ", index < 4 {
|
||||
index += 1
|
||||
}
|
||||
guard index <= 3, index < characters.count else { return nil }
|
||||
|
||||
let marker = characters[index]
|
||||
guard marker == "`" || marker == "~" else { return nil }
|
||||
let start = index
|
||||
while index < characters.count, characters[index] == marker {
|
||||
index += 1
|
||||
}
|
||||
let length = index - start
|
||||
guard length >= 3 else { return nil }
|
||||
|
||||
if opening {
|
||||
if marker == "`", characters[index...].contains("`") { return nil }
|
||||
} else if !characters[index...].allSatisfy(\.isWhitespace) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Fence(marker: marker, length: length)
|
||||
}
|
||||
|
||||
private static func isBlockquoteLine(_ line: String) -> Bool {
|
||||
let characters = Array(line)
|
||||
var index = 0
|
||||
while index < characters.count, characters[index] == " ", index < 4 {
|
||||
index += 1
|
||||
}
|
||||
return index <= 3 && index < characters.count && characters[index] == ">"
|
||||
}
|
||||
|
||||
private static func isIndentedCodeLine(_ line: String) -> Bool {
|
||||
let characters = Array(line)
|
||||
var index = 0
|
||||
while index < characters.count, characters[index] == " ", index < 4 {
|
||||
index += 1
|
||||
}
|
||||
return index == 4 || (index < characters.count && characters[index] == "\t")
|
||||
}
|
||||
|
||||
private static func hasMentionOutsideInlineCode(in text: String) -> Bool {
|
||||
let visible = strippingInlineCode(from: text)
|
||||
let characters = Array(visible)
|
||||
guard characters.count >= 6 else { return false }
|
||||
|
||||
for index in 0...(characters.count - 6) where characters[index] == "@" {
|
||||
let token = String(characters[index..<(index + 6)])
|
||||
guard token.caseInsensitiveCompare("@sybil") == .orderedSame else { continue }
|
||||
if index > 0, isEmailLocalPartCharacter(characters[index - 1]) { continue }
|
||||
if index + 6 < characters.count, isWordCharacter(characters[index + 6]) { continue }
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static func strippingInlineCode(from text: String) -> String {
|
||||
let characters = Array(text)
|
||||
var visible: [Character] = []
|
||||
var index = 0
|
||||
|
||||
while index < characters.count {
|
||||
guard characters[index] == "`" else {
|
||||
visible.append(characters[index])
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let start = index
|
||||
while index < characters.count, characters[index] == "`" {
|
||||
index += 1
|
||||
}
|
||||
let runLength = index - start
|
||||
if hasEscapedBacktick(before: start, in: characters) {
|
||||
visible.append(contentsOf: characters[start..<index])
|
||||
continue
|
||||
}
|
||||
|
||||
if let closingStart = matchingBacktickRun(after: index, length: runLength, in: characters) {
|
||||
index = closingStart + runLength
|
||||
} else {
|
||||
visible.append(contentsOf: characters[start..<index])
|
||||
}
|
||||
}
|
||||
|
||||
return String(visible)
|
||||
}
|
||||
|
||||
private static func matchingBacktickRun(after start: Int, length: Int, in characters: [Character]) -> Int? {
|
||||
var index = start
|
||||
while index < characters.count {
|
||||
guard characters[index] == "`" else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let runStart = index
|
||||
while index < characters.count, characters[index] == "`" {
|
||||
index += 1
|
||||
}
|
||||
if index - runStart == length { return runStart }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func hasEscapedBacktick(before index: Int, in characters: [Character]) -> Bool {
|
||||
guard index > 0 else { return false }
|
||||
var cursor = index
|
||||
while cursor > 0, characters[cursor - 1] == "\\" {
|
||||
cursor -= 1
|
||||
}
|
||||
return (index - cursor).isMultiple(of: 2) == false
|
||||
}
|
||||
|
||||
private static func isEmailLocalPartCharacter(_ character: Character) -> Bool {
|
||||
character.isLetter || character.isNumber || "!#$%&'*+-/=?^_`{|}~.".contains(character)
|
||||
}
|
||||
|
||||
private static func isWordCharacter(_ character: Character) -> Bool {
|
||||
character.isLetter || character.isNumber || character == "_"
|
||||
}
|
||||
}
|
||||
|
||||
extension Message {
|
||||
var isAgentAside: Bool {
|
||||
role == .assistant && metadata?.objectValue?["agentAside"]?.boolValue == true
|
||||
}
|
||||
|
||||
mutating func setAgentAside(_ value: Bool) {
|
||||
var object = metadata?.objectValue ?? [:]
|
||||
object["agentAside"] = .bool(value)
|
||||
metadata = .object(object)
|
||||
}
|
||||
}
|
||||
@@ -627,6 +627,7 @@ public struct CompletionStreamMeta: Codable, Sendable {
|
||||
public var callId: String?
|
||||
public var provider: Provider
|
||||
public var model: String
|
||||
public var agentAside: Bool? = nil
|
||||
}
|
||||
|
||||
public struct CompletionStreamDelta: Codable, Sendable {
|
||||
|
||||
@@ -2121,6 +2121,7 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func sendChat(content: String, attachments: [ChatAttachment], sendContext: ActiveSendContext) async throws {
|
||||
let optimisticAgentAside = SybilInlineMention.hasExplicitSybilMention(in: content)
|
||||
let optimisticUser = Message(
|
||||
id: "temp-user-\(UUID().uuidString)",
|
||||
createdAt: Date(),
|
||||
@@ -2135,7 +2136,8 @@ final class SybilViewModel {
|
||||
createdAt: Date(),
|
||||
role: .assistant,
|
||||
content: "",
|
||||
name: nil
|
||||
name: nil,
|
||||
metadata: optimisticAgentAside ? .object(["agentAside": .bool(true)]) : nil
|
||||
)
|
||||
|
||||
let optimisticMessages = (currentSelectedChat?.messages ?? []) + [optimisticUser, optimisticAssistant]
|
||||
@@ -2339,6 +2341,9 @@ final class SybilViewModel {
|
||||
if payload.chatId == chatID {
|
||||
pendingChatStates[chatID]?.chatID = payload.chatId
|
||||
}
|
||||
if let agentAside = payload.agentAside {
|
||||
setPendingAssistantAgentAside(agentAside, chatID: chatID)
|
||||
}
|
||||
|
||||
case let .toolCall(payload):
|
||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||
@@ -2528,6 +2533,17 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func setPendingAssistantAgentAside(_ value: Bool, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID],
|
||||
let assistantIndex = pending.messages.indices.last(where: {
|
||||
pending.messages[$0].id.hasPrefix("temp-assistant-")
|
||||
}) else {
|
||||
return
|
||||
}
|
||||
pending.messages[assistantIndex].setAgentAside(value)
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func applyPendingAssistantDelta(_ text: String, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
@Suite
|
||||
struct SybilInlineMentionTests {
|
||||
@Test func recognizesLiteralCaseInsensitiveTokens() {
|
||||
#expect(SybilInlineMention.hasExplicitSybilMention(in: "@sybil"))
|
||||
#expect(SybilInlineMention.hasExplicitSybilMention(in: "Could you help, @SYBIL?"))
|
||||
#expect(SybilInlineMention.hasExplicitSybilMention(in: "Please ask (@SyBiL), thanks."))
|
||||
}
|
||||
|
||||
@Test func ignoresEmailAddresses() {
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "Send mail to person@sybil.example"))
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "Mailbox: first.last+tag@sybil"))
|
||||
}
|
||||
|
||||
@Test func ignoresInlineFencedAndIndentedCode() {
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "Run `@sybil` after the build."))
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "Inline `example\n@sybil` across lines."))
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "```swift\n@sybil\n```"))
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "~~~\n@sybil\n~~~"))
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: " @sybil\n\t@sybil"))
|
||||
}
|
||||
|
||||
@Test func ignoresMarkdownBlockquotesAndLazyContinuation() {
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: "> @sybil\ncontinued @sybil"))
|
||||
#expect(!SybilInlineMention.hasExplicitSybilMention(in: " > @SYBIL"))
|
||||
#expect(SybilInlineMention.hasExplicitSybilMention(in: "> quoted @sybil\n\n@sybil"))
|
||||
}
|
||||
|
||||
@Test func streamMetaAcceptsOptionalAgentAsideFlag() throws {
|
||||
let legacyData = Data(#"{"chatId":"chat-1","provider":"openai","model":"test-model"}"#.utf8)
|
||||
let legacy = try JSONDecoder().decode(CompletionStreamMeta.self, from: legacyData)
|
||||
#expect(legacy.agentAside == nil)
|
||||
|
||||
let asideData = Data(#"{"chatId":"chat-1","provider":"openai","model":"test-model","agentAside":true}"#.utf8)
|
||||
let aside = try JSONDecoder().decode(CompletionStreamMeta.self, from: asideData)
|
||||
#expect(aside.agentAside == true)
|
||||
}
|
||||
|
||||
@Test func asidePresentationUsesAssistantMetadata() {
|
||||
let assistantAside = Message(
|
||||
id: "message-1",
|
||||
createdAt: .now,
|
||||
role: .assistant,
|
||||
content: "An Agent reply",
|
||||
name: "Sybil Agent",
|
||||
metadata: .object(["agentAside": .bool(true)])
|
||||
)
|
||||
let ordinaryAssistant = Message(
|
||||
id: "message-2",
|
||||
createdAt: .now,
|
||||
role: .assistant,
|
||||
content: "A provider reply",
|
||||
name: nil
|
||||
)
|
||||
#expect(assistantAside.isAgentAside)
|
||||
#expect(!ordinaryAssistant.isAgentAside)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user