diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index bfef25f75..8983da8a1 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -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(); const falTaskSyncInFlight = new Set(); -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(); } diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 07cdc1066..c80563da0 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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(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 }; } diff --git a/lib/chat/messages-route-access.ts b/lib/chat/messages-route-access.ts new file mode 100644 index 000000000..f58931bb1 --- /dev/null +++ b/lib/chat/messages-route-access.ts @@ -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) + ); +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 799345ab0..18d4700cf 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -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"); diff --git a/tests/routes/chat.test.ts b/tests/routes/chat.test.ts index 097c5647a..862669552 100644 --- a/tests/routes/chat.test.ts +++ b/tests/routes/chat.test.ts @@ -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();