Cache markdown and append realtime messages
This commit is contained in:
@@ -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<String, CharSequence> 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) {
|
||||
|
||||
@@ -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<Intent> conversationInfoLauncher;
|
||||
private ActivityResultLauncher<Intent> 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<String> currentIds = collectMessageIds(currentMessages);
|
||||
List<String> 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)) {
|
||||
|
||||
32
tests/android-chat-incremental-realtime-append.test.ts
Normal file
32
tests/android-chat-incremental-realtime-append.test.ts
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
31
tests/android-markdown-cache.test.ts
Normal file
31
tests/android-markdown-cache.test.ts
Normal file
@@ -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<String, CharSequence> 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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user