mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-06 19:32:58 +08:00
* feat(ios): export chat transcripts as Markdown via the share sheet * chore(ios): sync native i18n inventory
163 lines
6.5 KiB
Swift
163 lines
6.5 KiB
Swift
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)
|
|
}
|
|
}
|