

























@@ -44,6 +44,8 @@ public final class OpenClawChatViewModel {
4444 didSet { self.pendingRunCount = self.pendingRuns.count }
4545}
464647+private var pendingLocalUserEchoMessageIDsByRunID: [String: UUID] = [:]
48+4749@ObservationIgnored
4850private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
4951private let pendingRunTimeoutMs: UInt64 = 120_000
@@ -279,6 +281,7 @@ public final class OpenClawChatViewModel {
279281self.messages = Self.reconcileMessageIDs(
280282 previous: self.messages,
281283 incoming: Self.decodeMessages(payload.messages ?? []))
284+self.prunePendingLocalUserEchoMessageIDs()
282285self.sessionId = payload.sessionId
283286if !self.prefersExplicitThinkingLevel,
284287let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
@@ -393,6 +396,43 @@ public final class OpenClawChatViewModel {
393396return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
394397}
395398399+private func prunePendingLocalUserEchoMessageIDs() {
400+guard !self.pendingLocalUserEchoMessageIDsByRunID.isEmpty else { return }
401+let visibleMessageIDs = Set(self.messages.map(\.id))
402+self.pendingLocalUserEchoMessageIDsByRunID = self.pendingLocalUserEchoMessageIDsByRunID.filter {
403+self.pendingRuns.contains($0.key) && visibleMessageIDs.contains($0.value)
404+}
405+}
406+407+private func adoptPendingLocalUserEcho(incoming: OpenClawChatMessage) -> Bool {
408+guard let incomingKey = Self.userRefreshIdentityKey(for: incoming) else { return false }
409+guard let matchIndex = self.messages.lastIndex(where: { existing in
410+self.pendingLocalUserEchoMessageIDsByRunID.values.contains(existing.id)
411+ && Self.userRefreshIdentityKey(for: existing) == incomingKey
412+}) else {
413+return false
414+}
415+416+let existing = self.messages[matchIndex]
417+self.pendingLocalUserEchoMessageIDsByRunID = self.pendingLocalUserEchoMessageIDsByRunID.filter {
418+ $0.value != existing.id
419+}
420+var updated = self.messages
421+updated[matchIndex] = OpenClawChatMessage(
422+ id: existing.id,
423+ role: incoming.role,
424+ content: incoming.content,
425+ timestamp: incoming.timestamp ?? existing.timestamp,
426+ toolCallId: incoming.toolCallId,
427+ toolName: incoming.toolName,
428+ usage: incoming.usage,
429+ stopReason: incoming.stopReason,
430+ errorMessage: incoming.errorMessage)
431+self.messages = Self.dedupeMessages(updated)
432+self.prunePendingLocalUserEchoMessageIDs()
433+return true
434+}
435+396436private static func reconcileMessageIDs(
397437 previous: [OpenClawChatMessage],
398438 incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
@@ -619,12 +659,14 @@ public final class OpenClawChatViewModel {
619659 arguments: nil))
620660}
621661let userMessageTimestamp = Date().timeIntervalSince1970 * 1000
662+let userMessageID = UUID()
622663self.messages.append(
623664OpenClawChatMessage(
624- id: UUID(),
665+ id: userMessageID,
625666 role: "user",
626667 content: userContent,
627668 timestamp: userMessageTimestamp))
669+self.pendingLocalUserEchoMessageIDsByRunID[runId] = userMessageID
628670629671 // Clear input immediately for responsive UX (before network await)
630672self.input = ""
@@ -645,8 +687,10 @@ public final class OpenClawChatViewModel {
645687"chat.ui transport send accepted sessionKey=\(sessionKey) "
646688+ "localRunId=\(runId) remoteRunId=\(response.runId)")
647689if response.runId != runId {
690+let pendingUserMessageID = self.pendingLocalUserEchoMessageIDsByRunID.removeValue(forKey: runId)
648691self.clearPendingRun(runId)
649692self.pendingRuns.insert(response.runId)
693+self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID
650694self.armPendingRunTimeout(runId: response.runId)
651695}
652696await self.refreshHistoryAfterRun()
@@ -664,6 +708,7 @@ public final class OpenClawChatViewModel {
664708 userMessageTimestamp: userMessageTimestamp)
665709}
666710} catch {
711+self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
667712self.clearPendingRun(runId)
668713self.errorText = error.localizedDescription
669714self.logDiagnostic(
@@ -747,6 +792,7 @@ public final class OpenClawChatViewModel {
747792self.onSessionChanged?(next)
748793self.modelSelectionID = Self.defaultModelSelectionID
749794self.messages = []
795+self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
750796self.sessionId = nil
751797self.pendingToolCallsById = [:]
752798self.streamingAssistantText = nil
@@ -1262,14 +1308,19 @@ public final class OpenClawChatViewModel {
12621308}
1263130912641310guard let message = payload.message else { return }
1265-guard message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" else {
1266-return
1267-}
1268-if self.pendingRunCount > 0 {
1311+1312+let sanitized = Self.stripInboundMetadata(from: message)
1313+1314+ // The active client also receives the gateway's echo of the user turn it
1315+ // just sent. performSend already appended an optimistic row carrying a
1316+ // local client timestamp, while the echo carries a server timestamp, so
1317+ // the timestamp-keyed identity/dedupe paths below never collapse them.
1318+ // Adopt the server record only onto a still-visible row created by this
1319+ // client's pending send; same-content user turns from other clients must append.
1320+if self.adoptPendingLocalUserEcho(incoming: sanitized) {
12691321return
12701322}
127113231272-let sanitized = Self.stripInboundMetadata(from: message)
12731324let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [sanitized])
12741325self.messages = Self.dedupeMessages(reconciled)
12751326}
@@ -1565,6 +1616,7 @@ public final class OpenClawChatViewModel {
15651616self.messages = Self.reconcileRunRefreshMessages(
15661617 previous: self.messages,
15671618 incoming: Self.decodeMessages(payload.messages ?? []))
1619+self.prunePendingLocalUserEchoMessageIDs()
15681620self.sessionId = payload.sessionId
15691621if !self.prefersExplicitThinkingLevel,
15701622let level = Self.normalizedThinkingLevel(payload.thinkingLevel)
@@ -1597,6 +1649,7 @@ public final class OpenClawChatViewModel {
15971649private func clearPendingRun(_ runId: String) {
15981650let wasPending = self.pendingRuns.contains(runId)
15991651self.pendingRuns.remove(runId)
1652+self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
16001653self.pendingRunTimeoutTasks[runId]?.cancel()
16011654self.pendingRunTimeoutTasks[runId] = nil
16021655if wasPending {
@@ -1613,6 +1666,7 @@ public final class OpenClawChatViewModel {
16131666}
16141667self.pendingRunTimeoutTasks.removeAll()
16151668self.pendingRuns.removeAll()
1669+self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
16161670if let reason, !reason.isEmpty {
16171671self.errorText = reason
16181672for runId in runIds {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。