From 1b0f126d4f1b8a48a28e0440e467e03f7aef4497 Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 10 Apr 2026 22:12:58 +0800 Subject: [PATCH] Patch local chat realtime and align Caddy --- .../com/hyzq/boss/ProjectDetailActivity.java | 23 ++++++++++++ deployment/Caddyfile | 14 +++++++ src/app/api/v1/events/route.ts | 15 +++++++- .../v1/projects/[projectId]/messages/route.ts | 18 +-------- src/lib/boss-events.ts | 1 + src/lib/boss-projections.ts | 25 +++++++++++++ .../android-chat-local-realtime-patch.test.ts | 37 +++++++++++++++++++ tests/deployment-caddyfile.test.ts | 28 ++++++++++++++ 8 files changed, 144 insertions(+), 17 deletions(-) create mode 100644 tests/android-chat-local-realtime-patch.test.ts create mode 100644 tests/deployment-caddyfile.test.ts 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 6604bd5..135dc5a 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -356,9 +356,32 @@ public class ProjectDetailActivity extends BossScreenActivity { if (isDuplicateRealtimeEvent(eventFingerprint, now)) { return; } + if (tryApplyRealtimeMessagesPatch(event)) { + return; + } runOnUiThread(() -> scheduleRealtimeReload(!"project.messages.updated".equals(event.eventName))); } + private boolean tryApplyRealtimeMessagesPatch(BossRealtimeEvent event) { + if (event == null || !"project.messages.updated".equals(event.eventName)) { + return false; + } + JSONObject projectMessagesPayload = event.payload.optJSONObject("projectMessagesPayload"); + if (projectMessagesPayload == null) { + return false; + } + runOnUiThread(() -> { + if (reloadInFlight) { + scheduleRealtimeReload(false); + return; + } + renderNearBottom = isChatNearBottom(); + renderForcedScrollToBottom = false; + renderLoadedProjectSnapshot(new ProjectSnapshot(projectMessagesPayload, null, null)); + }); + return true; + } + private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { pruneRecentRealtimeEvents(now); Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); diff --git a/deployment/Caddyfile b/deployment/Caddyfile index 0a11c2b..3609c11 100644 --- a/deployment/Caddyfile +++ b/deployment/Caddyfile @@ -1,9 +1,23 @@ boss.hyzq.net { encode zstd gzip + + redir /gptpluscontrol /gptpluscontrol/ 308 + + handle /gptpluscontrol/* { + reverse_proxy 127.0.0.1:18081 + } + reverse_proxy 127.0.0.1:3000 } http://106.53.170.158 { encode zstd gzip + + redir /gptpluscontrol /gptpluscontrol/ 308 + + handle /gptpluscontrol/* { + reverse_proxy 127.0.0.1:18081 + } + reverse_proxy 127.0.0.1:3000 } diff --git a/src/app/api/v1/events/route.ts b/src/app/api/v1/events/route.ts index e20b390..488d532 100644 --- a/src/app/api/v1/events/route.ts +++ b/src/app/api/v1/events/route.ts @@ -3,6 +3,7 @@ import { jsonNoStore } from "@/lib/api-response"; import { requireRequestSession } from "@/lib/boss-auth"; import { subscribeBossEvents, type BossEventPayload } from "@/lib/boss-events"; import { + buildProjectMessagesRealtimePayload, getAuditSummaryView, getConversationHomeItemForProject, getConversationThreadItemForProject, @@ -24,11 +25,23 @@ function shouldEnrichConversationPatch(event: string, payload: Pick) { + return event === "project.messages.updated" && Boolean(payload.projectId?.trim()); +} + async function buildEventPayload(event: string, payload: BossEventPayload) { - if (!shouldEnrichConversationPatch(event, payload)) { + if (!shouldEnrichConversationPatch(event, payload) && !shouldEnrichProjectMessagesPatch(event, payload)) { return payload; } const state = await readState(); + if (shouldEnrichProjectMessagesPatch(event, payload)) { + return { + ...payload, + conversationItem: getConversationHomeItemForProject(state, String(payload.projectId ?? "")), + threadConversationItem: getConversationThreadItemForProject(state, String(payload.projectId ?? "")), + projectMessagesPayload: buildProjectMessagesRealtimePayload(state, String(payload.projectId ?? "")), + }; + } return { ...payload, conversationItem: getConversationHomeItemForProject(state, String(payload.projectId ?? "")), diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 6b4e2b6..8469611 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data"; import { jsonNoStore } from "@/lib/api-response"; +import { buildProjectMessagesRealtimePayload } from "@/lib/boss-projections"; import { getThreadConversationExecutionConflict, queueGroupDispatchPlan, @@ -36,21 +37,6 @@ function threadConversationFailureMessage(error?: string) { } } -function buildProjectMessagesPayload( - state: Awaited>, - projectId: string, -) { - const project = state.projects.find((item) => item.id === projectId); - if (!project) { - return null; - } - return { - ok: true, - project, - devices: state.devices.filter((device) => project.deviceIds.includes(device.id)), - }; -} - export async function GET( request: NextRequest, context: { params: Promise<{ projectId: string }> }, @@ -62,7 +48,7 @@ export async function GET( const { projectId } = await context.params; const state = await readState(); - const payload = buildProjectMessagesPayload(state, projectId); + const payload = buildProjectMessagesRealtimePayload(state, projectId); if (!payload) { return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } diff --git a/src/lib/boss-events.ts b/src/lib/boss-events.ts index 539137d..9283d3a 100644 --- a/src/lib/boss-events.ts +++ b/src/lib/boss-events.ts @@ -23,6 +23,7 @@ export interface BossEventPayload { note?: string; conversationItem?: unknown; threadConversationItem?: unknown; + projectMessagesPayload?: unknown; } type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void; diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index e4ab12e..588705e 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -548,6 +548,12 @@ export interface ConversationFolderView { threads: ConversationItem[]; } +export interface ProjectMessagesRealtimePayload { + ok: true; + project: Project; + devices: Device[]; +} + export function getConversationHomeItems(state: BossState): ConversationItem[] { const flatItems = getConversationItems(state); const projectMap = new Map(state.projects.map((project) => [project.id, project])); @@ -702,6 +708,25 @@ export function getConversationFolderView( }; } +export function buildProjectMessagesRealtimePayload( + state: BossState, + projectId: string, +): ProjectMessagesRealtimePayload | null { + const normalizedProjectId = projectId.trim(); + if (!normalizedProjectId) { + return null; + } + const project = state.projects.find((item) => item.id === normalizedProjectId); + if (!project) { + return null; + } + return { + ok: true, + project, + devices: state.devices.filter((device) => project.deviceIds.includes(device.id)), + }; +} + function resolveProjectAgentControls( state: BossState, projectId: string, diff --git a/tests/android-chat-local-realtime-patch.test.ts b/tests/android-chat-local-realtime-patch.test.ts new file mode 100644 index 0000000..02d131e --- /dev/null +++ b/tests/android-chat-local-realtime-patch.test.ts @@ -0,0 +1,37 @@ +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("events route enriches message events with a lightweight project chat payload", async () => { + const source = await readSource("../src/app/api/v1/events/route.ts"); + + assert.match( + source, + /projectMessagesPayload:\s*buildProjectMessagesRealtimePayload\(state,\s*String\(payload\.projectId \?\? ""\)\)/, + "expected realtime event route to include a lightweight project chat payload for message events", + ); +}); + +test("ProjectDetailActivity applies lightweight realtime chat payloads before scheduling reloads", async () => { + const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java"); + + assert.match( + source, + /if \(tryApplyRealtimeMessagesPatch\(event\)\) \{\s*return;\s*\}/, + "expected chat page to try a local realtime message patch before falling back to debounced reloads", + ); + assert.match( + source, + /JSONObject projectMessagesPayload = event\.payload\.optJSONObject\("projectMessagesPayload"\);/, + "expected chat page to read the lightweight message payload from realtime events", + ); + assert.match( + source, + /renderLoadedProjectSnapshot\(new ProjectSnapshot\(projectMessagesPayload,\s*null,\s*null\)\);/, + "expected chat page to render the local realtime payload without forcing a network request", + ); +}); diff --git a/tests/deployment-caddyfile.test.ts b/tests/deployment-caddyfile.test.ts new file mode 100644 index 0000000..07ee534 --- /dev/null +++ b/tests/deployment-caddyfile.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +test("deployment Caddyfile keeps boss and gptpluscontrol routes in a single site definition", async () => { + const source = await readFile(new URL("../deployment/Caddyfile", import.meta.url), "utf8"); + + assert.match( + source, + /boss\.hyzq\.net\s*\{/, + "expected deployment Caddyfile to define the main boss.hyzq.net site", + ); + assert.match( + source, + /handle \/gptpluscontrol\/\* \{\s*reverse_proxy 127\.0\.0\.1:18081\s*\}/s, + "expected deployment Caddyfile to preserve the GPT Plus Control route under the boss domain", + ); + assert.match( + source, + /reverse_proxy 127\.0\.0\.1:3000/, + "expected deployment Caddyfile to continue proxying boss-web to port 3000", + ); + assert.equal( + (source.match(/boss\.hyzq\.net\s*\{/g) ?? []).length, + 1, + "expected deployment Caddyfile to avoid duplicate boss.hyzq.net site definitions", + ); +});