From 05dc9d8788978e7ffbfae5b6cb0b51189884d5af Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 10 Apr 2026 22:40:49 +0800 Subject: [PATCH] Cache markdown and append realtime messages --- .../main/java/com/hyzq/boss/BossMarkdown.java | 18 ++++- .../com/hyzq/boss/ProjectDetailActivity.java | 81 +++++++++++++++++++ ...d-chat-incremental-realtime-append.test.ts | 32 ++++++++ tests/android-markdown-cache.test.ts | 31 +++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 tests/android-chat-incremental-realtime-append.test.ts create mode 100644 tests/android-markdown-cache.test.ts diff --git a/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java b/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java index e2c33e4..a0e753e 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java +++ b/android/app/src/main/java/com/hyzq/boss/BossMarkdown.java @@ -1,10 +1,12 @@ package com.hyzq.boss; import android.content.Context; +import android.content.res.Configuration; import android.graphics.Color; import android.graphics.Typeface; import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.text.SpannedString; import android.text.TextUtils; import android.text.style.BackgroundColorSpan; import android.text.style.BulletSpan; @@ -14,6 +16,7 @@ import android.text.style.QuoteSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; +import android.util.LruCache; import java.util.ArrayList; import java.util.List; @@ -25,6 +28,7 @@ public final class BossMarkdown { private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$"); private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$"); private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");; + private static final LruCache RENDER_CACHE = new LruCache<>(180); private BossMarkdown() {} @@ -32,6 +36,11 @@ public final class BossMarkdown { if (TextUtils.isEmpty(markdown) || TextUtils.isEmpty(markdown.trim())) { return "(空消息)"; } + String cacheKey = buildCacheKey(context, markdown, outgoing); + CharSequence cached = RENDER_CACHE.get(cacheKey); + if (cached != null) { + return cached; + } Palette palette = Palette.resolve(context, outgoing); SpannableStringBuilder builder = new SpannableStringBuilder(); String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n'); @@ -90,7 +99,14 @@ public final class BossMarkdown { } trimTrailingNewline(builder); - return builder; + CharSequence rendered = SpannedString.valueOf(builder); + RENDER_CACHE.put(cacheKey, rendered); + return rendered; + } + + private static String buildCacheKey(Context context, String markdown, boolean outgoing) { + int uiMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return (outgoing ? "out" : "in") + "|" + uiMode + "|" + markdown; } private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) { diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 135dc5a..5d3c618 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -80,6 +80,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private @Nullable JSONObject currentPendingDispatchPlan; private @Nullable JSONObject currentRejectedDispatchPlan; private @Nullable JSONObject currentParticipantsPayload; + private @Nullable JSONObject currentRenderedProjectPayload; private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection(); private ActivityResultLauncher conversationInfoLauncher; private ActivityResultLauncher masterAgentPromptLauncher; @@ -370,6 +371,9 @@ public class ProjectDetailActivity extends BossScreenActivity { if (projectMessagesPayload == null) { return false; } + if (tryAppendRealtimeMessagesPatch(projectMessagesPayload)) { + return true; + } runOnUiThread(() -> { if (reloadInFlight) { scheduleRealtimeReload(false); @@ -382,6 +386,71 @@ public class ProjectDetailActivity extends BossScreenActivity { return true; } + private boolean tryAppendRealtimeMessagesPatch(JSONObject projectMessagesPayload) { + if (currentRenderedProjectPayload == null + || contentLayout == null + || pendingOutgoingBubble != null + || masterAgentReplyWaiting + || masterAgentReplyTimedOut + || (selectionState != null && selectionState.multiSelecting)) { + return false; + } + JSONObject currentProject = currentRenderedProjectPayload.optJSONObject("project"); + JSONObject nextProject = projectMessagesPayload.optJSONObject("project"); + if (currentProject == null || nextProject == null) { + return false; + } + JSONArray currentMessages = currentProject.optJSONArray("messages"); + JSONArray nextMessages = nextProject.optJSONArray("messages"); + if (currentMessages == null || nextMessages == null) { + return false; + } + List currentIds = collectMessageIds(currentMessages); + List nextIds = collectMessageIds(nextMessages); + if (currentIds.isEmpty() || nextIds.size() <= currentIds.size()) { + return false; + } + for (int i = 0; i < currentIds.size(); i++) { + if (!currentIds.get(i).equals(nextIds.get(i))) { + return false; + } + } + runOnUiThread(() -> { + if (currentRenderedProjectPayload == null || contentLayout == null) { + renderLoadedProjectSnapshot(new ProjectSnapshot(projectMessagesPayload, null, null)); + return; + } + JSONObject project = projectMessagesPayload.optJSONObject("project"); + JSONArray devices = projectMessagesPayload.optJSONArray("devices"); + JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta"); + String title = project != null ? project.optString("name", "项目详情") : "项目详情"; + initialProjectName = title; + projectIsGroup = project != null && project.optBoolean("isGroup", false); + projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", projectCollaborationMode); + projectApprovalState = project == null ? "not_required" : project.optString("approvalState", projectApprovalState); + lightDispatchReminderEnabled = project != null && project.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled); + updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices)); + + selectionState = ProjectChatUiState.reconcileSelection(selectionState, nextIds); + renderNearBottom = isChatNearBottom(); + for (int i = currentIds.size(); i < nextMessages.length(); i++) { + JSONObject message = nextMessages.optJSONObject(i); + if (message == null) { + continue; + } + appendContent(buildMessageView(message)); + } + currentRenderedProjectPayload = copyJson(projectMessagesPayload); + setRefreshing(false); + updateSelectionUi(); + if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, false)) { + scrollChatToBottom(); + } + }); + return true; + } + private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { pruneRecentRealtimeEvents(now); Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); @@ -609,6 +678,7 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + currentRenderedProjectPayload = copyJson(payload); setRefreshing(false); updateSelectionUi(); if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, renderForcedScrollToBottom)) { @@ -2598,6 +2668,17 @@ public class ProjectDetailActivity extends BossScreenActivity { return ids; } + private JSONObject copyJson(@Nullable JSONObject source) { + if (source == null) { + return new JSONObject(); + } + try { + return new JSONObject(source.toString()); + } catch (org.json.JSONException ignored) { + return new JSONObject(); + } + } + @Nullable private String labelForMessageKind(String kind) { if (TextUtils.isEmpty(kind) || "text".equals(kind)) { diff --git a/tests/android-chat-incremental-realtime-append.test.ts b/tests/android-chat-incremental-realtime-append.test.ts new file mode 100644 index 0000000..3e238fe --- /dev/null +++ b/tests/android-chat-incremental-realtime-append.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +async function readSource(path: string) { + return readFile(new URL(path, import.meta.url), "utf8"); +} + +test("ProjectDetailActivity keeps a rendered project snapshot for append-only realtime message patches", async () => { + const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java"); + + assert.match( + source, + /private @Nullable JSONObject currentRenderedProjectPayload;/, + "expected chat page to keep the latest rendered project payload for incremental realtime diffs", + ); + assert.match( + source, + /if \(tryAppendRealtimeMessagesPatch\(projectMessagesPayload\)\) \{\s*return true;\s*\}/, + "expected chat page to try an append-only realtime patch before falling back to a full message rerender", + ); + assert.match( + source, + /private boolean tryAppendRealtimeMessagesPatch\(JSONObject projectMessagesPayload\)/, + "expected chat page to expose a dedicated append-only realtime patch helper", + ); + assert.match( + source, + /appendContent\(buildMessageView\(message\)\);/, + "expected append-only realtime patches to add only the new message views", + ); +}); diff --git a/tests/android-markdown-cache.test.ts b/tests/android-markdown-cache.test.ts new file mode 100644 index 0000000..6006099 --- /dev/null +++ b/tests/android-markdown-cache.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +test("BossMarkdown caches rendered markdown spans for repeated bodies", async () => { + const source = await readFile( + new URL("../android/app/src/main/java/com/hyzq/boss/BossMarkdown.java", import.meta.url), + "utf8", + ); + + assert.match( + source, + /private static final LruCache RENDER_CACHE = new LruCache<>\(/, + "expected markdown renderer to keep an LRU cache for rendered spans", + ); + assert.match( + source, + /String cacheKey = buildCacheKey\(context, markdown, outgoing\);/, + "expected markdown renderer to derive a stable cache key before parsing", + ); + assert.match( + source, + /CharSequence cached = RENDER_CACHE\.get\(cacheKey\);/, + "expected markdown renderer to reuse cached spans when available", + ); + assert.match( + source, + /RENDER_CACHE\.put\(cacheKey, rendered\);/, + "expected markdown renderer to save rendered spans into the cache", + ); +});