fix: allow scoped canvas chat history empty state

This commit is contained in:
134 2026-06-23 14:36:08 +08:00
parent de457b5dc3
commit 16ebbeb6ac
5 changed files with 151 additions and 21 deletions

View File

@ -18,6 +18,12 @@ import {
getFalImageTaskStatus,
resolveFalImageTaskToMarkdown,
} from "@/lib/ai/providers/fal-image";
import {
isAgentModeScopedChatId,
isScopedEmptyHistoryRequest,
isValidMessagesChatId,
normalizeMessagesChatId,
} from "@/lib/chat/messages-route-access";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@ -25,19 +31,6 @@ export const revalidate = 0;
const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
const falTaskLastPolledAt = new Map<string, number>();
const falTaskSyncInFlight = new Set<string>();
const AGENT_MODE_SCOPED_CHAT_SUFFIX = "a93e17a60001";
function isAgentModeScopedChatId(chatId: string) {
const normalized = String(chatId || "")
.trim()
.toLowerCase()
.replace(/-/g, "");
return (
/^[0-9a-f]{32}$/.test(normalized) &&
normalized.endsWith(AGENT_MODE_SCOPED_CHAT_SUFFIX)
);
}
function createEmptyMessagesResponse() {
return NextResponse.json(
@ -226,18 +219,27 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const chatId = request.nextUrl.searchParams.get("chatId");
const requestedChatId = request.nextUrl.searchParams.get("chatId");
if (!chatId) {
if (!requestedChatId) {
return NextResponse.json(
{ error: "Missing required parameter: chatId" },
{ status: 400 }
);
}
const chatId = normalizeMessagesChatId(requestedChatId);
if (!isValidMessagesChatId(chatId)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const scope = request.nextUrl.searchParams.get("scope");
const selectedChat = await getChatById({ id: chatId });
if (!selectedChat) {
if (isAgentModeScopedChatId(chatId)) {
if (
isAgentModeScopedChatId(chatId) ||
isScopedEmptyHistoryRequest({ chatId, scope })
) {
return createEmptyMessagesResponse();
}

View File

@ -1677,6 +1677,23 @@ export function CanvasChatPanel({
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
const rawChatId = toRawCanvasChatId(activeChatId);
const buildHistoryMessagesUrl = useCallback(
(targetRawChatId: string, options?: { cacheBust?: boolean }) => {
const params = new URLSearchParams({
chatId: targetRawChatId,
scope: chatScope,
});
if (options?.cacheBust) {
params.set("_t", String(Date.now()));
}
return `/api/messages?${params.toString()}`;
},
[chatScope]
);
const historyMessagesSWRKey = useMemo(
() => (rawChatId ? buildHistoryMessagesUrl(rawChatId) : null),
[buildHistoryMessagesUrl, rawChatId]
);
const { mutate } = useSWRConfig();
const [votingMessageId, setVotingMessageId] = useState<string | null>(null);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
@ -1718,7 +1735,7 @@ export function CanvasChatPanel({
// 从 API 加载历史消息
const { data: historyData, mutate: mutateHistoryData } = useSWR<{
messages: ChatMessage[];
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher, {
}>(historyMessagesSWRKey, fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 10000,
@ -4779,7 +4796,7 @@ export function CanvasChatPanel({
const syncMessagesFromDatabase = (dbMessages: ChatMessage[]) => {
if (finishedChatId !== activeChatIdRef.current) {
void mutate(`/api/messages?chatId=${finishedRawChatId}`);
void mutate(buildHistoryMessagesUrl(finishedRawChatId));
return;
}
@ -4798,9 +4815,12 @@ export function CanvasChatPanel({
};
const reloadMessagesOnce = async () => {
const response = await fetch(`/api/messages?chatId=${finishedRawChatId}&_t=${Date.now()}`, {
cache: "no-store",
});
const response = await fetch(
buildHistoryMessagesUrl(finishedRawChatId, { cacheBust: true }),
{
cache: "no-store",
}
);
if (!response.ok) {
return { hasResult: false, hasError: false };
}

View File

@ -0,0 +1,42 @@
const AGENT_MODE_SCOPED_CHAT_SUFFIX = "a93e17a60001";
const CHAT_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const EMPTY_HISTORY_SCOPES = new Set(["canvas", "agent"]);
export function normalizeMessagesChatId(chatId: string) {
const normalized = String(chatId || "").trim();
const canvasPrefix = "canvas-";
if (normalized.toLowerCase().startsWith(canvasPrefix)) {
const rawCanvasId = normalized.slice(canvasPrefix.length);
return CHAT_UUID_REGEX.test(rawCanvasId) ? rawCanvasId : normalized;
}
return normalized;
}
export function isValidMessagesChatId(chatId: string) {
return CHAT_UUID_REGEX.test(String(chatId || "").trim());
}
export function isAgentModeScopedChatId(chatId: string) {
const normalized = String(chatId || "")
.trim()
.toLowerCase()
.replace(/-/g, "");
return (
/^[0-9a-f]{32}$/.test(normalized) &&
normalized.endsWith(AGENT_MODE_SCOPED_CHAT_SUFFIX)
);
}
export function isScopedEmptyHistoryRequest(params: {
chatId: string;
scope?: string | null;
}) {
const scope = String(params.scope || "")
.trim()
.toLowerCase();
return (
EMPTY_HISTORY_SCOPES.has(scope) && isValidMessagesChatId(params.chatId)
);
}

View File

@ -27,6 +27,12 @@ import {
} from "@/lib/canvas-toolbar-config";
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
import {
isAgentModeScopedChatId,
isScopedEmptyHistoryRequest,
isValidMessagesChatId,
normalizeMessagesChatId,
} from "@/lib/chat/messages-route-access";
import {
CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE,
CANVAS_NODE_NOTE_ANNOTATION_SOURCE,
@ -131,6 +137,35 @@ import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
import type { ChatMessage } from "@/lib/types";
test.describe("canvas chat pure helpers", () => {
test("keeps canvas message empty-history access scoped and explicit", () => {
const canvasChatId = "11111111-1111-4111-8111-111111111111";
const agentScopedChatId = "11111111-1111-4111-8111-a93e17a60001";
expect(normalizeMessagesChatId(`canvas-${canvasChatId}`)).toBe(
canvasChatId
);
expect(normalizeMessagesChatId("canvas-not-a-uuid")).toBe(
"canvas-not-a-uuid"
);
expect(isValidMessagesChatId(canvasChatId)).toBe(true);
expect(isValidMessagesChatId("canvas-not-a-uuid")).toBe(false);
expect(isScopedEmptyHistoryRequest({ chatId: canvasChatId })).toBe(false);
expect(
isScopedEmptyHistoryRequest({ chatId: canvasChatId, scope: "canvas" })
).toBe(true);
expect(
isScopedEmptyHistoryRequest({ chatId: canvasChatId, scope: " AGENT " })
).toBe(true);
expect(
isScopedEmptyHistoryRequest({
chatId: "canvas-not-a-uuid",
scope: "canvas",
})
).toBe(false);
expect(isAgentModeScopedChatId(agentScopedChatId)).toBe(true);
expect(isAgentModeScopedChatId(canvasChatId)).toBe(false);
});
test("rejects stale autosave bases without blocking fresh or missing revisions", () => {
const currentUpdatedAt = new Date("2026-06-22T00:00:05.500Z");

View File

@ -39,6 +39,37 @@ test.describe
expect(message).toEqual(getMessageByErrorCode("bad_request:api"));
});
test("Ada can load an empty canvas-scoped message history", async ({
adaContext,
}) => {
const chatId = generateUUID();
const response = await adaContext.request.get(
`/api/messages?chatId=${chatId}&scope=canvas`
);
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ messages: [] });
const prefixedResponse = await adaContext.request.get(
`/api/messages?chatId=canvas-${generateUUID()}&scope=canvas`
);
expect(prefixedResponse.status()).toBe(200);
expect(await prefixedResponse.json()).toEqual({ messages: [] });
});
test("Ada cannot load missing unscoped message history", async ({
adaContext,
}) => {
const response = await adaContext.request.get(
`/api/messages?chatId=${generateUUID()}`
);
expect(response.status()).toBe(403);
expect(await response.json()).toEqual({ error: "Forbidden" });
});
test("Ada can invoke chat generation", async ({ adaContext }) => {
const chatId = generateUUID();