Patch local chat realtime and align Caddy
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<BossEventPay
|
||||
return event === "conversation.updated" || event === "project.messages.updated";
|
||||
}
|
||||
|
||||
function shouldEnrichProjectMessagesPatch(event: string, payload: Pick<BossEventPayload, "projectId">) {
|
||||
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 ?? "")),
|
||||
|
||||
@@ -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<ReturnType<typeof readState>>,
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface BossEventPayload {
|
||||
note?: string;
|
||||
conversationItem?: unknown;
|
||||
threadConversationItem?: unknown;
|
||||
projectMessagesPayload?: unknown;
|
||||
}
|
||||
|
||||
type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void;
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
tests/android-chat-local-realtime-patch.test.ts
Normal file
37
tests/android-chat-local-realtime-patch.test.ts
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
28
tests/deployment-caddyfile.test.ts
Normal file
28
tests/deployment-caddyfile.test.ts
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user