mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 09:01:16 +00:00
* feat(macos): wire sessions manager, assistant trace, talk, voice notes, and Listen into native chat * feat(clients): add input history, reply quoting, and per-session drafts to native chat composer * fix(clients): capture TTS server lease before gateway check and dedupe restored input-history entries * docs(clients): document reply-chip consume parity with web accepted-send semantics * fix(clients): retire sent-text recall stash and keep recall markers stable under prepends * test(clients): wait for outbox restore before asserting live-send paths * docs(clients): note cross-session outbox flush scheduling contract * chore(clients): split oversized chat files to satisfy SwiftLint length caps * chore(i18n): sync native app i18n inventory for new chat strings * chore: retrigger ci * chore(i18n): translate new chat strings and regenerate Apple catalogs * chore(i18n): regenerate Android localization resources for new chat strings * test(clients): await lifecycle cleanup before modeling replacement gateway traffic * chore(clients): split invoke timeout helper to satisfy SwiftLint length cap * chore(clients): drop unused OpenClawProtocol import from invoke timeout split
93 lines
3.7 KiB
Swift
93 lines
3.7 KiB
Swift
import Foundation
|
|
|
|
public struct OpenClawChatReplyTarget: Equatable, Sendable {
|
|
public let selectionID: UUID
|
|
public let messageID: UUID
|
|
public let text: String
|
|
public let senderLabel: String
|
|
|
|
public init(selectionID: UUID = UUID(), messageID: UUID, text: String, senderLabel: String) {
|
|
self.selectionID = selectionID
|
|
self.messageID = messageID
|
|
self.text = text
|
|
self.senderLabel = senderLabel
|
|
}
|
|
}
|
|
|
|
enum ChatReplyQuote {
|
|
static let targetTextUTF16Limit = 500
|
|
static let previewUTF16Limit = 120
|
|
|
|
static func targetText(_ text: String) -> String {
|
|
self.truncateUTF16Safe(text.trimmingCharacters(in: .whitespacesAndNewlines), limit: self.targetTextUTF16Limit)
|
|
}
|
|
|
|
static func previewText(_ text: String) -> (text: String, isTruncated: Bool) {
|
|
let truncated = self.truncateUTF16Safe(text, limit: self.previewUTF16Limit)
|
|
return (truncated, text.utf16.count > self.previewUTF16Limit)
|
|
}
|
|
|
|
static func prepend(message: String, replyTarget: OpenClawChatReplyTarget) -> String {
|
|
let senderLabel = replyTarget.senderLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let label = self.escapeMarkdownInline(senderLabel.isEmpty ? "User" : senderLabel)
|
|
let text = replyTarget.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
// LF-only detection matches the web encoder (chat-send.ts
|
|
// prependReplyQuote) byte-for-byte; normalizing CR here would make
|
|
// native quotes diverge from web transcripts for identical targets.
|
|
if !text.contains("\n") {
|
|
return "> **\(label):** \(text)\n\n\(message)"
|
|
}
|
|
let quoted = text
|
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
.map { "> \($0)" }
|
|
.joined(separator: "\n")
|
|
return "> **\(label):**\n\(quoted)\n\n\(message)"
|
|
}
|
|
|
|
static func truncateUTF16Safe(_ text: String, limit: Int) -> String {
|
|
guard limit >= 0, text.utf16.count > limit else { return text }
|
|
let utf16 = text.utf16
|
|
var end = utf16.index(utf16.startIndex, offsetBy: limit)
|
|
if end > utf16.startIndex,
|
|
end < utf16.endIndex,
|
|
(0xD800...0xDBFF).contains(utf16[utf16.index(before: end)]),
|
|
(0xDC00...0xDFFF).contains(utf16[end])
|
|
{
|
|
end = utf16.index(before: end)
|
|
}
|
|
return String(decoding: utf16[..<end], as: UTF16.self)
|
|
}
|
|
|
|
private static func escapeMarkdownInline(_ value: String) -> String {
|
|
let escaped = CharacterSet(charactersIn: "\\`*_{}[]()#+-.!|>")
|
|
return value.unicodeScalars.reduce(into: "") { result, scalar in
|
|
if escaped.contains(scalar) { result.append("\\") }
|
|
result.unicodeScalars.append(scalar)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension OpenClawChatViewModel {
|
|
public func clearReplyTarget() {
|
|
self.replyTarget = nil
|
|
}
|
|
|
|
func setReplyTarget(messageID: UUID, text: String, senderLabel: String) {
|
|
let text = ChatReplyQuote.targetText(text)
|
|
guard !text.isEmpty else { return }
|
|
self.replyTarget = OpenClawChatReplyTarget(
|
|
messageID: messageID,
|
|
text: text,
|
|
senderLabel: senderLabel.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
|
|
/// Accepted sends consume the chip even if newer text was typed meanwhile:
|
|
/// the quote was delivered with the submitted message, and web behaves the
|
|
/// same (chat-send.ts clears chatReplyTarget on accept by messageId alone).
|
|
/// Selection identity is already stricter: a re-selected target survives.
|
|
func consumeReplyTarget(_ target: OpenClawChatReplyTarget?) {
|
|
guard let target, self.replyTarget == target else { return }
|
|
self.replyTarget = nil
|
|
}
|
|
}
|