diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index de902f1f..0c400c8b 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -74,7 +74,9 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly Action? _post; private readonly object _gate = new(); private readonly object _toolMetaSaveGate = new(); + private readonly object _attachmentMetaSaveGate = new(); private readonly string _toolMetaCacheFilePath; + private readonly string _attachmentMetaCacheFilePath; private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes private long _toolMetaSaveVersion; private readonly Dictionary _timelines = new(); @@ -95,6 +97,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider // Keyed by gateway sessionId (immutable UUID). Persisted to disk // so that history reconstruction on restart can recover tool names. private Dictionary> _toolMetaCache; + private Dictionary> _attachmentMetaCache; // Track recently-sent local user message texts so we can suppress // SSE echoes while still displaying messages from other clients. private readonly Dictionary> _localSentTexts = new(); @@ -149,16 +152,24 @@ public OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? post { } - internal OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? post, string toolMetaCacheFilePath) + internal OpenClawChatDataProvider( + IChatGatewayBridge bridge, + Action? post, + string toolMetaCacheFilePath, + string? attachmentMetaCacheFilePath = null) { _bridge = bridge ?? throw new ArgumentNullException(nameof(bridge)); _post = post; _toolMetaCacheFilePath = !string.IsNullOrWhiteSpace(toolMetaCacheFilePath) ? toolMetaCacheFilePath : throw new ArgumentException("Tool metadata cache path is required.", nameof(toolMetaCacheFilePath)); + _attachmentMetaCacheFilePath = !string.IsNullOrWhiteSpace(attachmentMetaCacheFilePath) + ? attachmentMetaCacheFilePath + : DefaultAttachmentMetaCacheFilePath(_toolMetaCacheFilePath); _status = bridge.CurrentStatus; _persistedAbortedIds = LoadAbortedIds(); _toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath); + _attachmentMetaCache = LoadAttachmentMetaCache(_attachmentMetaCacheFilePath); _lastChatState = LoadLastChatState(); // Seed models from whatever the bridge already knows about (a connect @@ -236,16 +247,14 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation // blank even if the typed message was empty. Uses a unique prefix // ("\u200B📎 " / "\u200B🖼️ ") with a zero-width space to prevent // false positives from normal user text. - var displayText = trimmed; + var safeUserText = EscapeUntrustedAttachmentMarkerLines(trimmed); + var displayText = safeUserText; if (hasAttachments) { - var chips = string.Join("\n", attachments!.Select(a => - a.Type == "image" - ? $"\u200B🖼️ {a.FileName}" - : $"\u200B📎 {a.FileName}")); - displayText = string.IsNullOrEmpty(trimmed) + var chips = BuildAttachmentMarkerLines(attachments!); + displayText = string.IsNullOrEmpty(safeUserText) ? chips - : $"{trimmed}\n{chips}"; + : $"{safeUserText}\n{chips}"; } // 1. Optimistically add the user message + flag turn active. @@ -288,6 +297,8 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation try { await _bridge.SendChatMessageAsync(trimmed, threadId, sessionId, attachments); + if (hasAttachments) + CacheAttachmentMeta(sessionId, threadId, trimmed, attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); } catch (Exception ex) { @@ -451,21 +462,25 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr Logger.Info($"[ChatHistory] Found {cachedTools.Count} cached tool metadata entries for session"); bool nextAssistantIsAborted = false; + var attachmentMatcher = CreateAttachmentMetaMatcher(history.SessionId, threadId); foreach (var msg in ordered) { - if (string.IsNullOrEmpty(msg.Text)) continue; - + var roleLower = msg.Role?.ToLowerInvariant() ?? ""; + var rawText = msg.Text ?? string.Empty; var ts = msg.Ts > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(msg.Ts).ToLocalTime() : (DateTimeOffset?)null; var msgMeta = new ChatEntryMetadata(ts, modelAtLoad); - var roleLower = msg.Role?.ToLowerInvariant() ?? ""; // Cap per-message text up front so heuristics, logging, // and the reducer all see the same bounded value // (chat rubber-duck MEDIUM 4). - var text = TruncateForChatEntry(msg.Text); + var text = TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(rawText)); + if (roleLower == "user") + text = RehydrateAttachmentMarkers(attachmentMatcher, text, msg.Ts); + + if (string.IsNullOrEmpty(text)) continue; // Check if this user message was aborted (persisted __openclaw.id match) if (roleLower == "user") @@ -1257,7 +1272,7 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) ChatEntryMetadata? userMeta; lock (_gate) { userMeta = BuildLiveMetaLocked(msgThreadId, message.Ts); } ApplyEventAndPublish(msgThreadId, - new ChatUserMessageEvent(TruncateForChatEntry(message.Text)), + new ChatUserMessageEvent(TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(message.Text))), userMeta); } return; @@ -3103,6 +3118,20 @@ internal sealed class CachedToolMeta public string Label { get; set; } = ""; } + /// Attachment display metadata persisted without attachment bytes. + internal sealed class CachedAttachmentMeta + { + public long Ts { get; set; } + public string Text { get; set; } = ""; + public List Attachments { get; set; } = new(); + } + + internal sealed class CachedAttachmentItem + { + public string FileName { get; set; } = ""; + public bool IsImage { get; set; } + } + private static string DefaultToolMetaCacheFilePath { get @@ -3116,12 +3145,25 @@ private static string DefaultToolMetaCacheFilePath } } + private static string DefaultAttachmentMetaCacheFilePath(string toolMetaCacheFilePath) + { + var dir = Path.GetDirectoryName(toolMetaCacheFilePath); + return Path.Combine( + string.IsNullOrEmpty(dir) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : dir, + "attachment-metadata.json"); + } + /// Max sessions to keep in the tool metadata cache. internal const int MaxCachedSessions = 20; /// Max tool entries per session in the cache. internal const int MaxToolEntriesPerSession = 500; + /// Max attachment-bearing user messages per session in the cache. + internal const int MaxAttachmentEntriesPerSession = 500; + private static Dictionary> LoadToolMetaCache(string cacheFilePath) { try @@ -3138,6 +3180,244 @@ private static Dictionary> LoadToolMetaCache(string } } + private static Dictionary> LoadAttachmentMetaCache(string cacheFilePath) + { + try + { + if (!File.Exists(cacheFilePath)) + return new(); + var json = File.ReadAllText(cacheFilePath); + var dict = System.Text.Json.JsonSerializer.Deserialize>>(json); + return dict ?? new(); + } + catch + { + return new(); + } + } + + private void SaveAttachmentMetaCache() + { + try + { + Dictionary> snapshot; + lock (_gate) + { + snapshot = _attachmentMetaCache.ToDictionary( + kv => kv.Key, + kv => kv.Value.Select(e => new CachedAttachmentMeta + { + Ts = e.Ts, + Text = e.Text, + Attachments = e.Attachments.Select(a => new CachedAttachmentItem + { + FileName = a.FileName, + IsImage = a.IsImage + }).ToList() + }).ToList(), + StringComparer.Ordinal); + } + + if (snapshot.Count > MaxCachedSessions) + { + var toRemove = snapshot + .OrderBy(kv => kv.Value.Count > 0 ? kv.Value[^1].Ts : 0) + .Take(snapshot.Count - MaxCachedSessions) + .Select(kv => kv.Key) + .ToList(); + foreach (var k in toRemove) snapshot.Remove(k); + } + + var json = System.Text.Json.JsonSerializer.Serialize(snapshot, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + + lock (_attachmentMetaSaveGate) + { + var dir = Path.GetDirectoryName(_attachmentMetaCacheFilePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + var tempPath = _attachmentMetaCacheFilePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(tempPath, json); + File.Move(tempPath, _attachmentMetaCacheFilePath, overwrite: true); + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + // Best-effort cleanup; persistence remains best-effort. + } + } + } + } + catch { /* best-effort persistence */ } + } + + private void CacheAttachmentMeta( + string? sessionId, + string threadId, + string text, + IReadOnlyList attachments, + long tsMs) + { + if (attachments.Count == 0) + return; + + var items = attachments + .Where(a => !string.IsNullOrWhiteSpace(a.FileName)) + .Select(a => new CachedAttachmentItem + { + FileName = a.FileName, + IsImage = string.Equals(a.Type, "image", StringComparison.OrdinalIgnoreCase) + }) + .ToList(); + if (items.Count == 0) + return; + + lock (_gate) + { + if (_disposed) + return; + + var key = !string.IsNullOrEmpty(sessionId) ? sessionId! : threadId; + if (!_attachmentMetaCache.TryGetValue(key, out var list)) + { + list = new List(); + _attachmentMetaCache[key] = list; + } + + list.Add(new CachedAttachmentMeta + { + Ts = tsMs, + Text = TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(text)), + Attachments = items + }); + + if (list.Count > MaxAttachmentEntriesPerSession) + list.RemoveRange(0, list.Count - MaxAttachmentEntriesPerSession); + } + + SaveAttachmentMetaCache(); + } + + private AttachmentMetaMatcher CreateAttachmentMetaMatcher(string? sessionId, string threadId) + { + var entries = new List(); + lock (_gate) + { + if (!string.IsNullOrEmpty(sessionId) && + _attachmentMetaCache.TryGetValue(sessionId!, out var sessionEntries)) + entries.AddRange(CloneAttachmentMeta(sessionEntries)); + + if (!string.IsNullOrEmpty(threadId) && + (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) && + _attachmentMetaCache.TryGetValue(threadId, out var threadEntries)) + entries.AddRange(CloneAttachmentMeta(threadEntries)); + } + + return new AttachmentMetaMatcher(entries.OrderBy(e => e.Ts).ToList()); + } + + private static List CloneAttachmentMeta(List entries) => + entries.Select(e => new CachedAttachmentMeta + { + Ts = e.Ts, + Text = e.Text, + Attachments = e.Attachments.Select(a => new CachedAttachmentItem + { + FileName = a.FileName, + IsImage = a.IsImage + }).ToList() + }).ToList(); + + private sealed class AttachmentMetaMatcher + { + private static readonly TimeSpan MatchWindow = TimeSpan.FromHours(24); + private readonly List _entries; + private readonly bool[] _used; + + public AttachmentMetaMatcher(List entries) + { + _entries = entries; + _used = new bool[entries.Count]; + } + + public CachedAttachmentMeta? TryMatch(string text, long historyTsMs) + { + for (int i = 0; i < _entries.Count; i++) + { + if (_used[i]) + continue; + + var entry = _entries[i]; + if (!string.Equals(entry.Text, text, StringComparison.Ordinal)) + continue; + + if (historyTsMs > 0 && entry.Ts > 0 && + Math.Abs(historyTsMs - entry.Ts) > MatchWindow.TotalMilliseconds) + continue; + + _used[i] = true; + return entry; + } + + return null; + } + } + + private static string RehydrateAttachmentMarkers(AttachmentMetaMatcher matcher, string text, long historyTsMs) + { + var match = matcher.TryMatch(text, historyTsMs); + if (match is null || match.Attachments.Count == 0) + return text; + + var markerLines = BuildAttachmentMarkerLines(match.Attachments); + return string.IsNullOrEmpty(text) + ? markerLines + : $"{text}\n{markerLines}"; + } + + private static string BuildAttachmentMarkerLines(IEnumerable attachments) => + string.Join("\n", attachments.Select(a => + string.Equals(a.Type, "image", StringComparison.OrdinalIgnoreCase) + ? $"\u200B🖼️ {a.FileName}" + : $"\u200B📎 {a.FileName}")); + + private static string BuildAttachmentMarkerLines(IEnumerable attachments) => + string.Join("\n", attachments.Select(a => + a.IsImage + ? $"\u200B🖼️ {a.FileName}" + : $"\u200B📎 {a.FileName}")); + + internal static string EscapeUntrustedAttachmentMarkerLines(string? text) + { + if (string.IsNullOrEmpty(text)) + return text ?? string.Empty; + + var lines = text.Split('\n'); + var changed = false; + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var trimmedStart = line.TrimStart(); + if (trimmedStart.StartsWith("\u200B🖼️ ", StringComparison.Ordinal) || + trimmedStart.StartsWith("\u200B📎 ", StringComparison.Ordinal)) + { + var prefixLength = line.Length - trimmedStart.Length; + lines[i] = string.Concat(line.AsSpan(0, prefixLength), trimmedStart.AsSpan(1)); + changed = true; + } + } + + return changed ? string.Join('\n', lines) : text; + } + private void SaveToolMetaCache(long? expectedVersion = null) { try diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index f64818db..72ec278d 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -1024,11 +1024,6 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst attachmentNames.Add(("🖼️", trimLine.Substring(4).Trim(), true)); else if (trimLine.StartsWith("\u200B📎 ")) attachmentNames.Add(("📎", trimLine.Substring(3).Trim(), false)); - // Also match legacy format without zero-width space for backward compat - else if (trimLine.StartsWith("🖼️ ") && trimLine.Length < 260) - attachmentNames.Add(("🖼️", trimLine.Substring(3).Trim(), true)); - else if (trimLine.StartsWith("📎 ") && trimLine.Length < 260) - attachmentNames.Add(("📎", trimLine.Substring(2).Trim(), false)); else messageLines.Add(line); } diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index b1c3ae03..ad888db6 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -66,10 +66,16 @@ public Task SendChatAbortAsync(string runId, string? sessionKey = null) } private static (FakeBridge bridge, OpenClawChatDataProvider provider, List snapshots, List notifications) - CreateProvider(SessionInfo[]? initial = null) + CreateProvider(SessionInfo[]? initial = null, string? toolMetaCachePath = null, string? attachmentMetaCachePath = null) { var bridge = new FakeBridge { Sessions = initial ?? Array.Empty() }; - var provider = new OpenClawChatDataProvider(bridge); + var provider = toolMetaCachePath is null && attachmentMetaCachePath is null + ? new OpenClawChatDataProvider(bridge) + : new OpenClawChatDataProvider( + bridge, + post: null, + toolMetaCacheFilePath: toolMetaCachePath ?? Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "tool-metadata.json"), + attachmentMetaCacheFilePath: attachmentMetaCachePath); var snapshots = new List(); var notifications = new List(); provider.Changed += (_, e) => snapshots.Add(e.Snapshot); @@ -1840,6 +1846,120 @@ public async Task SendMessageAsync_WithAttachment_SendsThroughInterface() Assert.Contains("test.txt", userEntry.Text); } + [Fact] + public async Task AttachmentMetadata_PersistsAndRehydratesFromHistory() + { + using var tempDir = new TempDirectory(); + var toolPath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + var attachmentPath = Path.Combine(tempDir.DirectoryPath, "attachment-metadata.json"); + var sentTs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var (_, provider1, _, _) = CreateProvider(new[] { MainSession() }, toolPath, attachmentPath); + await provider1.LoadAsync(); + await provider1.SendMessageAsync("main", "Check this", default, new[] + { + new ChatAttachment + { + Type = "file", + MimeType = "text/plain", + FileName = "test.txt", + Content = Convert.ToBase64String(new byte[] { 72, 105 }), + SizeBytes = 2 + } + }); + + var (bridge2, provider2, snapshots, _) = CreateProvider(new[] { MainSession() }, toolPath, attachmentPath); + bridge2.HistoryBehavior = key => Task.FromResult(new ChatHistoryInfo + { + SessionKey = key ?? "", + SessionId = "session-1", + Messages = new[] + { + new ChatMessageInfo { Role = "user", Text = "Check this", State = "final", Ts = sentTs } + } + }); + + await provider2.LoadHistoryAsync("main"); + + var userEntry = snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User); + Assert.Equal("Check this\n\u200B📎 test.txt", userEntry.Text); + } + + [Fact] + public async Task AttachmentMetadata_RehydratesAttachmentOnlyHistoryMessage() + { + using var tempDir = new TempDirectory(); + var toolPath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + var attachmentPath = Path.Combine(tempDir.DirectoryPath, "attachment-metadata.json"); + var sentTs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var (_, provider1, _, _) = CreateProvider(new[] { MainSession() }, toolPath, attachmentPath); + await provider1.LoadAsync(); + await provider1.SendMessageAsync("main", "", default, new[] + { + new ChatAttachment + { + Type = "image", + MimeType = "image/png", + FileName = "screenshot.png", + Content = Convert.ToBase64String(new byte[] { 1, 2, 3 }), + SizeBytes = 3 + } + }); + + var (bridge2, provider2, snapshots, _) = CreateProvider(new[] { MainSession() }, toolPath, attachmentPath); + bridge2.HistoryBehavior = key => Task.FromResult(new ChatHistoryInfo + { + SessionKey = key ?? "", + SessionId = "session-1", + Messages = new[] + { + new ChatMessageInfo { Role = "user", Text = "", State = "final", Ts = sentTs } + } + }); + + await provider2.LoadHistoryAsync("main"); + + var userEntry = snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User); + Assert.Equal("\u200B🖼️ screenshot.png", userEntry.Text); + } + + [Fact] + public async Task AttachmentMetadata_DoesNotRehydratePastedMarkerTextWithoutSidecar() + { + using var tempDir = new TempDirectory(); + var toolPath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + var attachmentPath = Path.Combine(tempDir.DirectoryPath, "attachment-metadata.json"); + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }, toolPath, attachmentPath); + bridge.HistoryBehavior = key => Task.FromResult(new ChatHistoryInfo + { + SessionKey = key ?? "", + SessionId = "session-1", + Messages = new[] + { + new ChatMessageInfo { Role = "user", Text = "\u200B📎 spoof.txt", State = "final", Ts = 1 } + } + }); + + await provider.LoadHistoryAsync("main"); + + var userEntry = snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User); + Assert.Equal("📎 spoof.txt", userEntry.Text); + } + + [Fact] + public async Task SendMessageAsync_WithoutAttachment_EscapesPastedMarkerText() + { + var (_, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "\u200B📎 spoof.txt"); + + var userEntry = snapshots[0].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User); + Assert.Equal("📎 spoof.txt", userEntry.Text); + } + // ── Auto-reload on connect: OnSessionsUpdated eager history load ── [Fact] @@ -2229,4 +2349,27 @@ public void Info(string message) { } public void Warn(string message) { } public void Error(string message, Exception? ex = null) { } } + + private sealed class TempDirectory : IDisposable + { + public string DirectoryPath { get; } = Path.Combine(Path.GetTempPath(), "openclaw-chat-attachments-" + Guid.NewGuid().ToString("N")); + + public TempDirectory() + { + Directory.CreateDirectory(DirectoryPath); + } + + public void Dispose() + { + try + { + if (Directory.Exists(DirectoryPath)) + Directory.Delete(DirectoryPath, recursive: true); + } + catch + { + // Test cleanup is best-effort. + } + } + } }