mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-06 07:12:54 +08:00
feat(ios): export chat transcripts as Markdown via the share sheet (#100417)
* feat(ios): export chat transcripts as Markdown via the share sheet * chore(ios): sync native i18n inventory
This commit is contained in:
committed by
GitHub
parent
aa66581749
commit
8c19dbfb62
@@ -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",
|
||||
|
||||
12
apps/ios/Sources/Chat/ChatTranscriptShareSheet.swift
Normal file
12
apps/ios/Sources/Chat/ChatTranscriptShareSheet.swift
Normal file
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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\")"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: """
|
||||
<think>Do not export this reasoning.</think><final>
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| code | `ok` |
|
||||
|
||||
```swift
|
||||
print("hi")
|
||||
```
|
||||
</final>
|
||||
""",
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user