diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 8a5ce7acf33e..524da9d7d0bf 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -7259,7 +7259,31 @@ }, { "kind": "ui-localized-call", - "line": 100, + "line": 97, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Unable to Export Transcript", + "surface": "apple", + "id": "native.apple.e5524d5756a62744" + }, + { + "kind": "ui-call", + "line": 101, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "OK", + "surface": "apple", + "id": "native.apple.942d6a10fd90d6e3" + }, + { + "kind": "ui-call", + "line": 105, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "OpenClaw could not prepare the Markdown file.", + "surface": "apple", + "id": "native.apple.3548c8d1ab43e85e" + }, + { + "kind": "ui-localized-call", + "line": 125, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "What would you like to work on?", "surface": "apple", @@ -7267,7 +7291,7 @@ }, { "kind": "ui-call", - "line": 111, + "line": 136, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "The session attaches once the gateway is ready.", "surface": "apple", @@ -7275,23 +7299,39 @@ }, { "kind": "ui-modifier", - "line": 187, + "line": 212, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Opens Settings / Gateway", "surface": "apple", "id": "native.apple.d19c9577e0fdfea7" }, { - "kind": "conditional-branch", + "kind": "ui-call", "line": 237, "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Export Transcript", + "surface": "apple", + "id": "native.apple.ced228bb4d20a726" + }, + { + "kind": "ui-modifier", + "line": 247, + "path": "apps/ios/Sources/Design/ChatProTab.swift", + "source": "Chat actions", + "surface": "apple", + "id": "native.apple.940e1f943750c94b" + }, + { + "kind": "conditional-branch", + "line": 300, + "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Connected", "surface": "apple", "id": "native.apple.b635acd93246d67b" }, { "kind": "conditional-branch", - "line": 237, + "line": 300, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Unavailable", "surface": "apple", @@ -7299,7 +7339,7 @@ }, { "kind": "conditional-branch", - "line": 248, + "line": 311, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Connect to a gateway", "surface": "apple", @@ -7307,7 +7347,7 @@ }, { "kind": "conditional-branch", - "line": 248, + "line": 311, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Message \\(self.agentDisplayName)...", "surface": "apple", @@ -7315,7 +7355,7 @@ }, { "kind": "ui-localized-call", - "line": 298, + "line": 361, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Check OpenClaw status", "surface": "apple", @@ -7323,7 +7363,7 @@ }, { "kind": "ui-localized-call", - "line": 299, + "line": 362, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Summarize the current OpenClaw status and tell me what needs attention.", "surface": "apple", @@ -7331,7 +7371,7 @@ }, { "kind": "ui-localized-call", - "line": 302, + "line": 365, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "What can I control here?", "surface": "apple", @@ -7339,7 +7379,7 @@ }, { "kind": "ui-localized-call", - "line": 303, + "line": 366, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Show me which phone controls and device capabilities are available right now.", "surface": "apple", @@ -7347,7 +7387,7 @@ }, { "kind": "ui-localized-call", - "line": 306, + "line": 369, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Help me start voice chat", "surface": "apple", @@ -7355,7 +7395,7 @@ }, { "kind": "ui-localized-call", - "line": 307, + "line": 370, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Help me start a realtime voice session from this phone.", "surface": "apple", diff --git a/apps/ios/Sources/Chat/ChatTranscriptShareSheet.swift b/apps/ios/Sources/Chat/ChatTranscriptShareSheet.swift new file mode 100644 index 000000000000..14b07af9c264 --- /dev/null +++ b/apps/ios/Sources/Chat/ChatTranscriptShareSheet.swift @@ -0,0 +1,12 @@ +import SwiftUI +import UIKit + +struct ChatTranscriptShareSheet: UIViewControllerRepresentable { + let fileURL: URL + + func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: [self.fileURL], applicationActivities: nil) + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) {} +} diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index c087630fff42..bc056c64e81a 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -3,9 +3,16 @@ import OpenClawProtocol import SwiftUI struct ChatProTab: View { + private struct TranscriptShareItem: Identifiable { + let id = UUID() + let fileURL: URL + } + @Environment(NodeAppModel.self) private var appModel @State private var viewModel: OpenClawChatViewModel? @State private var viewModelTransportModeID = "" + @State private var transcriptShareItem: TranscriptShareItem? + @State private var showsTranscriptExportError = false let headerLeadingAction: OpenClawSidebarHeaderAction? let headerTitle: String? let showsAgentBadge: Bool @@ -75,11 +82,29 @@ struct ChatProTab: View { self.headerIdentityBadge } } + ToolbarItem(placement: .topBarTrailing) { + self.chatActionsMenu + } ToolbarItem(placement: .topBarTrailing) { self.connectionStatusButton .accessibilityIdentifier("chat-gateway-status") } } + .sheet(item: self.$transcriptShareItem) { item in + ChatTranscriptShareSheet(fileURL: item.fileURL) + } + .alert( + String(localized: "Unable to Export Transcript"), + isPresented: self.$showsTranscriptExportError) + { + Button(role: .cancel) {} label: { + Text("OK") + .font(OpenClawType.body) + } + } message: { + Text("OpenClaw could not prepare the Markdown file.") + .font(OpenClawType.body) + } } @ViewBuilder @@ -203,6 +228,44 @@ struct ChatProTab: View { .padding(.horizontal, 6) } + private var chatActionsMenu: some View { + Menu { + Button { + self.exportTranscript() + } label: { + Label { + Text("Export Transcript") + .font(OpenClawType.body) + } icon: { + Image(systemName: "square.and.arrow.up") + } + } + .disabled(self.viewModel == nil) + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Chat actions") + } + + private func exportTranscript() { + guard let viewModel else { return } + let title = viewModel.sessions.first { $0.key == viewModel.sessionKey }?.displayName + let filename = ChatTranscriptExporter.filename( + sessionTitle: title, + sessionKey: viewModel.sessionKey) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenClawTranscripts", isDirectory: true) + let fileURL = directory.appendingPathComponent(filename, isDirectory: false) + + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try viewModel.exportTranscriptMarkdown().write(to: fileURL, atomically: true, encoding: .utf8) + self.transcriptShareItem = TranscriptShareItem(fileURL: fileURL) + } catch { + self.showsTranscriptExportError = true + } + } + private var gatewayConnected: Bool { guard self.gatewayDisplayState == .connected else { return false diff --git a/apps/ios/Tests/OpenClawTypographyTests.swift b/apps/ios/Tests/OpenClawTypographyTests.swift index 4b0ba4459644..15f35e500237 100644 --- a/apps/ios/Tests/OpenClawTypographyTests.swift +++ b/apps/ios/Tests/OpenClawTypographyTests.swift @@ -155,6 +155,7 @@ struct OpenClawTypographyTests { contentsOf: Self.sourceURL("Design/SettingsChannelsDestination.swift"), encoding: .utf8) let docs = try String(contentsOf: Self.sourceURL("Design/OpenClawDocsScreen.swift"), encoding: .utf8) + let chatTab = try String(contentsOf: Self.sourceURL("Design/ChatProTab.swift"), encoding: .utf8) let chatTypography = try String( contentsOf: Self.iosRootURL() .deletingLastPathComponent() @@ -172,6 +173,10 @@ struct OpenClawTypographyTests { #expect(proComponents.contains("@Environment(\\.isEnabled)")) #expect(proComponents.contains(".animation(.easeOut(duration: 0.15), value: self.isEnabled)")) + #expect(chatTab.contains("Text(\"Export Transcript\")")) + #expect(chatTab.contains(".font(OpenClawType.body)")) + #expect(!chatTab.contains("Button(\"Export Transcript\")")) + #expect(!quickSetup.contains("Button(\"Close\")")) #expect(quickSetup.contains("Text(\"Quick Setup\")")) #expect(quickSetup.contains("Text(\"Close\")")) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift new file mode 100644 index 000000000000..7ef938afb582 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptExporter.swift @@ -0,0 +1,162 @@ +import Foundation + +public enum ChatTranscriptExporter { + private static let maxFileStemUTF8Count = 240 + + public static func markdown( + sessionTitle: String?, + sessionKey: String, + messages: [OpenClawChatMessage]) -> String + { + let title = self.resolvedTitle(sessionTitle: sessionTitle, sessionKey: sessionKey) + let timestampFormatter = ISO8601DateFormatter() + timestampFormatter.formatOptions = [.withInternetDateTime] + timestampFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + var sections = ["# \(title)"] + for message in messages where self.shouldExport(message) { + let timestamp = self.timestamp(message.timestamp, formatter: timestampFormatter) + let heading = "### \(self.displayRole(message.role)) — \(timestamp)" + let body = self.body(for: message) + sections.append([heading, body].filter { !$0.isEmpty }.joined(separator: "\n\n")) + } + return sections.joined(separator: "\n\n") + "\n" + } + + public static func filename(sessionTitle: String?, sessionKey: String) -> String { + let title = sessionTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let stem = self.sanitizedFileStem(title) + ?? self.sanitizedFileStem(key) + ?? "chat-transcript" + let boundedStem = self.boundedFileStem(stem) + return "\(boundedStem.isEmpty ? "chat-transcript" : boundedStem).md" + } + + private static func sanitizedFileStem(_ value: String) -> String? { + let forbidden = CharacterSet(charactersIn: "/\\:*?\"<>|").union(.controlCharacters) + var segments: [String] = [] + var current = "" + + for scalar in value.unicodeScalars { + if forbidden.contains(scalar) { + segments.append(current) + current = "" + } else { + current.unicodeScalars.append(scalar) + } + } + segments.append(current) + + let edgeCharacters = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ".-")) + let stem = segments + .map { segment in + segment + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + .trimmingCharacters(in: edgeCharacters) + } + .filter { !$0.isEmpty } + .joined(separator: "-") + return stem.isEmpty ? nil : stem + } + + private static func boundedFileStem(_ stem: String) -> String { + var result = "" + for character in stem { + let candidate = result + String(character) + guard candidate.utf8.count <= self.maxFileStemUTF8Count else { break } + result = candidate + } + return result + } + + private static func resolvedTitle(sessionTitle: String?, sessionKey: String) -> String { + let title = sessionTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + return title.split(whereSeparator: { $0.isNewline }).joined(separator: " ") + } + let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? "Chat transcript" : key.split(whereSeparator: { $0.isNewline }).joined(separator: " ") + } + + private static func shouldExport(_ message: OpenClawChatMessage) -> Bool { + let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard role != "system" else { return false } + if !self.attachments(in: message).isEmpty { + return true + } + let text = self.visibleText(in: message) + guard !text.isEmpty else { return false } + return role == "user" || AssistantTextParser.hasVisibleContent(in: text) + } + + private static func body(for message: OpenClawChatMessage) -> String { + var parts: [String] = [] + let text = self.visibleText(in: message) + if !text.isEmpty { + parts.append(text) + } + parts.append(contentsOf: self.attachments(in: message).map { attachment in + let filename = attachment.fileName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return "_[attachment: \(filename.isEmpty ? "Attachment" : filename)]_" + }) + return parts.joined(separator: "\n\n") + } + + private static func primaryText(in message: OpenClawChatMessage) -> String { + let parts = message.content.compactMap { content -> String? in + let kind = (content.type ?? "text").lowercased() + guard kind == "text" || kind.isEmpty else { return nil } + return content.text + } + return OpenClawChatMessage.displayText( + contentText: parts.joined(separator: "\n"), + role: message.role, + stopReason: message.stopReason, + errorMessage: message.errorMessage) + } + + private static func visibleText(in message: OpenClawChatMessage) -> String { + let text = self.primaryText(in: message) + let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard role != "user" else { return text } + return AssistantTextParser.visibleSegments(from: text) + .map(\.text) + .joined(separator: "\n\n") + } + + private static func attachments(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] { + message.content.filter { content in + let kind = (content.type ?? "text").lowercased() + return kind == "file" || kind == "attachment" + } + } + + private static func displayRole(_ role: String) -> String { + let normalized = role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return switch normalized { + case "user": "User" + case "assistant": "Assistant" + default: + normalized.isEmpty + ? "Message" + : normalized.prefix(1).uppercased() + normalized.dropFirst() + } + } + + private static func timestamp(_ value: Double?, formatter: ISO8601DateFormatter) -> String { + guard let value else { return "Unknown time" } + return formatter.string(from: Date(timeIntervalSince1970: value / 1000)) + } +} + +extension OpenClawChatViewModel { + public func exportTranscriptMarkdown() -> String { + let title = self.sessions.first { $0.key == self.sessionKey }?.displayName + return ChatTranscriptExporter.markdown( + sessionTitle: title, + sessionKey: self.sessionKey, + messages: self.messages) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift new file mode 100644 index 000000000000..a52cc93f3bd3 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatTranscriptExporterTests.swift @@ -0,0 +1,105 @@ +import Testing +@testable import OpenClawChatUI + +@Suite("ChatTranscriptExporter") +struct ChatTranscriptExporterTests { + @Test func `formats visible messages and attachments`() { + let messages = [ + self.message(role: "system", text: "Hidden setup", timestamp: 0), + self.message(role: "user", text: "Hello, 世界 👋", timestamp: 0), + self.message( + role: "assistant", + text: """ + Do not export this reasoning. + | Item | Value | + | --- | --- | + | code | `ok` | + + ```swift + print("hi") + ``` + + """, + timestamp: 1000), + OpenClawChatMessage( + role: "user", + content: [ + OpenClawChatMessageContent( + type: "attachment", + text: nil, + mimeType: "text/plain", + fileName: "résumé.txt", + content: nil), + ], + timestamp: 2000), + self.message(role: "assistant", text: " ", timestamp: 3000), + ] + + let markdown = ChatTranscriptExporter.markdown( + sessionTitle: "Project 🐾", + sessionKey: "agent:main", + messages: messages) + + #expect(markdown == """ + # Project 🐾 + + ### User — 1970-01-01T00:00:00Z + + Hello, 世界 👋 + + ### Assistant — 1970-01-01T00:00:01Z + + | Item | Value | + | --- | --- | + | code | `ok` | + + ```swift + print("hi") + ``` + + ### User — 1970-01-01T00:00:02Z + + _[attachment: résumé.txt]_ + + """) + } + + @Test func `sanitizes filename`() { + #expect( + ChatTranscriptExporter.filename( + sessionTitle: " Project / Q3: \"Résumé\"? ", + sessionKey: "fallback") == "Project-Q3-Résumé.md") + #expect( + ChatTranscriptExporter.filename( + sessionTitle: "///", + sessionKey: "fallback") == "fallback.md") + + let emojiFilename = ChatTranscriptExporter.filename( + sessionTitle: String(repeating: "🐾", count: 200), + sessionKey: "fallback") + #expect(emojiFilename.utf8.count <= 243) + #expect(emojiFilename.hasSuffix(".md")) + } + + @Test func `empty transcript contains header only`() { + #expect( + ChatTranscriptExporter.markdown( + sessionTitle: nil, + sessionKey: "agent:main", + messages: []) == "# agent:main\n") + } + + private func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage { + OpenClawChatMessage( + role: role, + content: [ + OpenClawChatMessageContent( + type: "text", + text: text, + mimeType: nil, + fileName: nil, + content: nil), + ], + timestamp: timestamp) + } +}