diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 630f69919f6..29b3182a578 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -40003,7 +40003,7 @@ }, { "kind": "conditional-branch", - "line": 635, + "line": 628, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Voice note", "surface": "apple", @@ -40011,7 +40011,7 @@ }, { "kind": "ui-call", - "line": 692, + "line": 685, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Writing", "surface": "apple", @@ -40019,7 +40019,7 @@ }, { "kind": "ui-call", - "line": 726, + "line": 719, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Preparing audio…", "surface": "apple", @@ -40027,7 +40027,7 @@ }, { "kind": "ui-call", - "line": 729, + "line": 722, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Speaking…", "surface": "apple", @@ -40035,7 +40035,7 @@ }, { "kind": "conditional-branch", - "line": 737, + "line": 730, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Preparing audio, tap to cancel", "surface": "apple", @@ -40043,7 +40043,7 @@ }, { "kind": "conditional-branch", - "line": 738, + "line": 731, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Speaking, tap to stop", "surface": "apple", @@ -41219,7 +41219,7 @@ }, { "kind": "conditional-branch", - "line": 125, + "line": 122, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift", "source": "Message", "surface": "apple", @@ -41275,7 +41275,7 @@ }, { "kind": "ui-call", - "line": 1145, + "line": 1138, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Copy Message", "surface": "apple", @@ -41283,7 +41283,7 @@ }, { "kind": "ui-call", - "line": 1168, + "line": 1161, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Open Full Message", "surface": "apple", @@ -41291,7 +41291,7 @@ }, { "kind": "ui-call", - "line": 1187, + "line": 1180, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Rewind to Here", "surface": "apple", @@ -41299,7 +41299,7 @@ }, { "kind": "ui-call", - "line": 1207, + "line": 1200, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Fork from Here", "surface": "apple", @@ -41307,7 +41307,7 @@ }, { "kind": "ui-localized-call", - "line": 1233, + "line": 1226, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Reply", "surface": "apple", @@ -41315,7 +41315,7 @@ }, { "kind": "ui-localized-call", - "line": 1243, + "line": 1236, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "You", "surface": "apple", @@ -41323,7 +41323,7 @@ }, { "kind": "ui-localized-call", - "line": 1245, + "line": 1238, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Assistant", "surface": "apple", @@ -41331,7 +41331,7 @@ }, { "kind": "ui-call", - "line": 1311, + "line": 1304, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Loading chat", "surface": "apple", @@ -41339,7 +41339,7 @@ }, { "kind": "ui-modifier", - "line": 1391, + "line": 1384, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Dismiss", "surface": "apple", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 711a21a3864..f7d3b454356 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -501,14 +501,7 @@ private struct ChatMessageBody: View { } private var inlineAttachments: [OpenClawChatMessageContent] { - self.message.content.filter { content in - switch content.type ?? "text" { - case "file", "attachment": - true - default: - false - } - } + self.message.content.filter(\.isInlineAttachment) } private var inlineWidgets: [OpenClawChatCanvasPreview] { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index 7682a82b5f7..1aa1ffb408f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -127,6 +127,16 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { public let details: AnyCodable? public let isError: Bool? + /// Gateway media and historical file attachments must stay visible in both chat and exports. + var isInlineAttachment: Bool { + switch self.type?.lowercased() { + case "file", "attachment", "image", "audio": + true + default: + false + } + } + public init( type: String?, text: String?, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift index 928d8420d41..b9c85194d20 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift @@ -109,10 +109,7 @@ public enum ChatTranscriptExporter { } private static func attachments(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] { - message.content.filter { content in - let kind = (content.type ?? "text").lowercased() - return kind == "file" || kind == "attachment" - } + message.content.filter(\.isInlineAttachment) } private static func displayRole(_ role: String) -> String { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index bb32ea53a84..1c18c8beda4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -1090,14 +1090,7 @@ extension OpenClawChatView { } private func hasInlineAttachments(in message: OpenClawChatMessage) -> Bool { - message.content.contains { content in - switch content.type ?? "text" { - case "file", "attachment": - true - default: - false - } - } + message.content.contains(where: \.isInlineAttachment) } private func toolCalls(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift index a52cc93f3bd..788a18cbb99 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift @@ -64,6 +64,131 @@ struct ChatTranscriptExporterTests { """) } + @Test(arguments: ["file", "attachment", "image", "audio", "FILE", "ATTACHMENT", "IMAGE", "AuDiO"]) + func `classifies gateway media and historical file attachments consistently`(type: String) { + let content = OpenClawChatMessageContent( + type: type, + text: nil, + mimeType: nil, + fileName: nil, + content: nil) + + #expect(content.isInlineAttachment) + } + + @Test(arguments: ["text", "canvas", "toolCall", "tool_result"]) + func `does not classify unrelated content as an attachment`(type: String) { + let content = OpenClawChatMessageContent( + type: type, + text: nil, + mimeType: "image/png", + fileName: "not-an-attachment.png", + content: nil) + + #expect(!content.isInlineAttachment) + } + + @Test func `does not classify content without a type as an attachment`() { + let content = OpenClawChatMessageContent( + type: nil, + text: nil, + mimeType: "image/png", + fileName: "not-an-attachment.png", + content: nil) + + #expect(!content.isInlineAttachment) + } + + @Test(arguments: ["user", "assistant"], ["image", "audio", "IMAGE", "AuDiO"]) + func `exports canonical media-only messages`(role: String, type: String) { + let isAudio = type.lowercased() == "audio" + let filename = isAudio ? "voice-note.m4a" : "photo.png" + let message = OpenClawChatMessage( + role: role, + content: [ + OpenClawChatMessageContent( + type: type, + text: nil, + mimeType: isAudio ? "audio/mp4" : "image/png", + fileName: filename, + content: nil), + ], + timestamp: 0) + + let markdown = ChatTranscriptExporter.markdown( + sessionTitle: "Media chat", + sessionKey: "agent:main", + messages: [message]) + + #expect(markdown == """ + # Media chat + + ### \(role == "user" ? "User" : "Assistant") — 1970-01-01T00:00:00Z + + _[attachment: \(filename)]_ + + """) + } + + @Test(arguments: ["image", "audio", "IMAGE", "AuDiO"]) + func `preserves canonical media alongside message text`(type: String) { + let isAudio = type.lowercased() == "audio" + let filename = isAudio ? "voice-note.m4a" : "photo.png" + let message = OpenClawChatMessage( + role: "user", + content: [ + OpenClawChatMessageContent( + type: "text", + text: "Here is the attachment.", + mimeType: nil, + fileName: nil, + content: nil), + OpenClawChatMessageContent( + type: type, + text: nil, + mimeType: isAudio ? "audio/mp4" : "image/png", + fileName: filename, + content: nil), + ], + timestamp: 0) + + let markdown = ChatTranscriptExporter.markdown( + sessionTitle: "Media chat", + sessionKey: "agent:main", + messages: [message]) + + #expect(markdown == """ + # Media chat + + ### User — 1970-01-01T00:00:00Z + + Here is the attachment. + + _[attachment: \(filename)]_ + + """) + } + + @Test(arguments: ["canvas", "toolCall", "tool_result"]) + func `does not export non-attachment content as media`(type: String) { + let message = OpenClawChatMessage( + role: "assistant", + content: [ + OpenClawChatMessageContent( + type: type, + text: nil, + mimeType: "image/png", + fileName: "not-an-attachment.png", + content: nil), + ], + timestamp: 0) + + #expect(ChatTranscriptExporter.markdown( + sessionTitle: "Media chat", + sessionKey: "agent:main", + messages: [message]) == "# Media chat\n") + } + @Test func `sanitizes filename`() { #expect( ChatTranscriptExporter.filename(