Patch local chat realtime and align Caddy

This commit is contained in:
kris
2026-04-10 22:12:58 +08:00
parent a084688e35
commit 1b0f126d4f
8 changed files with 144 additions and 17 deletions

View File

@@ -356,9 +356,32 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (isDuplicateRealtimeEvent(eventFingerprint, now)) { if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return; return;
} }
if (tryApplyRealtimeMessagesPatch(event)) {
return;
}
runOnUiThread(() -> scheduleRealtimeReload(!"project.messages.updated".equals(event.eventName))); 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) { private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now); pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);

View File

@@ -1,9 +1,23 @@
boss.hyzq.net { boss.hyzq.net {
encode zstd gzip encode zstd gzip
redir /gptpluscontrol /gptpluscontrol/ 308
handle /gptpluscontrol/* {
reverse_proxy 127.0.0.1:18081
}
reverse_proxy 127.0.0.1:3000 reverse_proxy 127.0.0.1:3000
} }
http://106.53.170.158 { http://106.53.170.158 {
encode zstd gzip encode zstd gzip
redir /gptpluscontrol /gptpluscontrol/ 308
handle /gptpluscontrol/* {
reverse_proxy 127.0.0.1:18081
}
reverse_proxy 127.0.0.1:3000 reverse_proxy 127.0.0.1:3000
} }

View File

@@ -3,6 +3,7 @@ import { jsonNoStore } from "@/lib/api-response";
import { requireRequestSession } from "@/lib/boss-auth"; import { requireRequestSession } from "@/lib/boss-auth";
import { subscribeBossEvents, type BossEventPayload } from "@/lib/boss-events"; import { subscribeBossEvents, type BossEventPayload } from "@/lib/boss-events";
import { import {
buildProjectMessagesRealtimePayload,
getAuditSummaryView, getAuditSummaryView,
getConversationHomeItemForProject, getConversationHomeItemForProject,
getConversationThreadItemForProject, getConversationThreadItemForProject,
@@ -24,11 +25,23 @@ function shouldEnrichConversationPatch(event: string, payload: Pick<BossEventPay
return event === "conversation.updated" || event === "project.messages.updated"; 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) { async function buildEventPayload(event: string, payload: BossEventPayload) {
if (!shouldEnrichConversationPatch(event, payload)) { if (!shouldEnrichConversationPatch(event, payload) && !shouldEnrichProjectMessagesPatch(event, payload)) {
return payload; return payload;
} }
const state = await readState(); 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 { return {
...payload, ...payload,
conversationItem: getConversationHomeItemForProject(state, String(payload.projectId ?? "")), conversationItem: getConversationHomeItemForProject(state, String(payload.projectId ?? "")),

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth"; import { requireRequestSession } from "@/lib/boss-auth";
import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data"; import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data";
import { jsonNoStore } from "@/lib/api-response"; import { jsonNoStore } from "@/lib/api-response";
import { buildProjectMessagesRealtimePayload } from "@/lib/boss-projections";
import { import {
getThreadConversationExecutionConflict, getThreadConversationExecutionConflict,
queueGroupDispatchPlan, 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( export async function GET(
request: NextRequest, request: NextRequest,
context: { params: Promise<{ projectId: string }> }, context: { params: Promise<{ projectId: string }> },
@@ -62,7 +48,7 @@ export async function GET(
const { projectId } = await context.params; const { projectId } = await context.params;
const state = await readState(); const state = await readState();
const payload = buildProjectMessagesPayload(state, projectId); const payload = buildProjectMessagesRealtimePayload(state, projectId);
if (!payload) { if (!payload) {
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
} }

View File

@@ -23,6 +23,7 @@ export interface BossEventPayload {
note?: string; note?: string;
conversationItem?: unknown; conversationItem?: unknown;
threadConversationItem?: unknown; threadConversationItem?: unknown;
projectMessagesPayload?: unknown;
} }
type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void; type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void;

View File

@@ -548,6 +548,12 @@ export interface ConversationFolderView {
threads: ConversationItem[]; threads: ConversationItem[];
} }
export interface ProjectMessagesRealtimePayload {
ok: true;
project: Project;
devices: Device[];
}
export function getConversationHomeItems(state: BossState): ConversationItem[] { export function getConversationHomeItems(state: BossState): ConversationItem[] {
const flatItems = getConversationItems(state); const flatItems = getConversationItems(state);
const projectMap = new Map(state.projects.map((project) => [project.id, project])); 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( function resolveProjectAgentControls(
state: BossState, state: BossState,
projectId: string, projectId: string,

View 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",
);
});

View 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",
);
});