diff --git a/app/api/canvas/clawd-chat/cancel/route.ts b/app/api/canvas/clawd-chat/cancel/route.ts index 138a372e1..fc9d08fda 100644 --- a/app/api/canvas/clawd-chat/cancel/route.ts +++ b/app/api/canvas/clawd-chat/cancel/route.ts @@ -6,6 +6,10 @@ import { buildClawdCancelKey, cancelClawdRequest, } from "@/lib/openclaw-chat/cancel-registry"; +import { + OPENCLAW_AUTH_REQUIRED_CODE, + OPENCLAW_AUTH_REQUIRED_MESSAGE, +} from "@/lib/openclaw-chat/errors"; const cancelSchema = z.object({ id: z.string().uuid(), @@ -15,7 +19,13 @@ const cancelSchema = z.object({ export async function POST(request: Request) { const session = await auth().catch(() => null); if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json( + { + error: OPENCLAW_AUTH_REQUIRED_MESSAGE, + code: OPENCLAW_AUTH_REQUIRED_CODE, + }, + { status: 401 } + ); } const parsed = cancelSchema.safeParse(await request.json().catch(() => null)); diff --git a/app/api/canvas/clawd-chat/history/route.ts b/app/api/canvas/clawd-chat/history/route.ts index 822587749..97bbf669d 100644 --- a/app/api/canvas/clawd-chat/history/route.ts +++ b/app/api/canvas/clawd-chat/history/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { getClawdChatsByUserId } from "@/lib/db/queries"; +import { + OPENCLAW_AUTH_REQUIRED_CODE, + OPENCLAW_AUTH_REQUIRED_MESSAGE, +} from "@/lib/openclaw-chat/errors"; export const dynamic = "force-dynamic"; export const revalidate = 0; @@ -9,7 +13,13 @@ export async function GET(request: Request) { const session = await auth().catch(() => null); if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json( + { + error: OPENCLAW_AUTH_REQUIRED_MESSAGE, + code: OPENCLAW_AUTH_REQUIRED_CODE, + }, + { status: 401 } + ); } const url = new URL(request.url); diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 24a8d5ab7..b7c7a7ebe 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -13,6 +13,11 @@ import { normalizeOpenClawChatConfig, OPENCLAW_CHAT_CONFIG_KEY, } from "@/lib/openclaw-chat/config"; +import { + OPENCLAW_AUTH_REQUIRED_CODE, + OPENCLAW_AUTH_REQUIRED_MESSAGE, + OPENCLAW_UPGRADE_RETRY_MESSAGE, +} from "@/lib/openclaw-chat/errors"; import { buildClawdCancelKey, cancelClawdRequest, @@ -33,8 +38,6 @@ import { } from "@/lib/openclaw-chat/media-attachments"; import { generateUUID } from "@/lib/utils"; -const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试"; - const requestSchema = z.object({ id: z.string().uuid(), message: z.string().trim().max(8000).optional().default(""), @@ -636,7 +639,7 @@ function streamOpenClawBackgroundTask(params: { wasCanceled ? "final" : "error", wasCanceled ? { canceled: true } - : { error: CLAW_UPGRADE_RETRY_MESSAGE } + : { error: OPENCLAW_UPGRADE_RETRY_MESSAGE } ) ) ); @@ -665,7 +668,13 @@ export async function POST(request: Request) { const session = await auth().catch(() => null); if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json( + { + error: OPENCLAW_AUTH_REQUIRED_MESSAGE, + code: OPENCLAW_AUTH_REQUIRED_CODE, + }, + { status: 401 } + ); } const parsed = await parseClawdChatRequest(request); @@ -685,11 +694,14 @@ export async function POST(request: Request) { const config = await readOpenClawConfig(); if (!config.enabled) { - return NextResponse.json({ error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 }); + return NextResponse.json( + { error: OPENCLAW_UPGRADE_RETRY_MESSAGE }, + { status: 503 } + ); } if (!config.baseUrl || !config.token) { return NextResponse.json( - { error: CLAW_UPGRADE_RETRY_MESSAGE }, + { error: OPENCLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 } ); } @@ -906,7 +918,7 @@ export async function POST(request: Request) { streamController.enqueue( encoder.encode( encodeSseEvent("error", { - error: CLAW_UPGRADE_RETRY_MESSAGE, + error: OPENCLAW_UPGRADE_RETRY_MESSAGE, }) ) ); @@ -917,7 +929,7 @@ export async function POST(request: Request) { streamController.enqueue( encoder.encode( encodeSseEvent("error", { - error: CLAW_UPGRADE_RETRY_MESSAGE, + error: OPENCLAW_UPGRADE_RETRY_MESSAGE, }) ) ); @@ -952,7 +964,7 @@ export async function POST(request: Request) { }); return NextResponse.json( { - error: CLAW_UPGRADE_RETRY_MESSAGE, + error: OPENCLAW_UPGRADE_RETRY_MESSAGE, code: errorCode || undefined, }, { status: response.status } @@ -1000,7 +1012,7 @@ export async function POST(request: Request) { console.error("[ClawdChat API] OpenClaw request failed:", error); return NextResponse.json( { - error: CLAW_UPGRADE_RETRY_MESSAGE, + error: OPENCLAW_UPGRADE_RETRY_MESSAGE, }, { status: isAbortError ? 504 : upstreamStatus } ); @@ -1014,7 +1026,7 @@ export async function POST(request: Request) { if (!answer) { console.error("[ClawdChat API] OpenClaw empty answer:", upstreamPayload); return NextResponse.json( - { error: CLAW_UPGRADE_RETRY_MESSAGE }, + { error: OPENCLAW_UPGRADE_RETRY_MESSAGE }, { status: 502 } ); } diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index e2fbd855a..7d4c3fbb6 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -10997,6 +10997,7 @@ export default function CanvasEditorLegacyPage({ chatScope="canvas" onClose={() => setIsPanelOpen(false)} onOpenFileList={() => setPanelMode("fileList")} + onRequireLogin={openLoginDialog} selectedAgentPresetId={ isCanvasAgentModeEnabled ? selectedAgentPresetId : null } diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 6aba7c4d9..24d302213 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -87,6 +87,12 @@ import { normalizeVideoCanvasStoryboardReference, type VideoCanvasStoryboardReferenceRequest, } from "@/lib/video-canvas/storyboard-reference"; +import { + getOpenClawClientErrorMessage, + isOpenClawAuthRequiredError, + OPENCLAW_AUTH_REQUIRED_MESSAGE, + OPENCLAW_UPGRADE_RETRY_MESSAGE, +} from "@/lib/openclaw-chat/errors"; import type { AgentPreset, AgentWorkflowComposerContext, @@ -344,6 +350,7 @@ interface CanvasChatPanelProps { canvasAgentFeatureSettings?: CanvasAgentFeatureSettings; composerReferenceRequest?: VideoCanvasStoryboardReferenceRequest | null; onComposerReferenceHandled?: (requestId: string) => void; + onRequireLogin?: (message?: string) => void; onStoryboardReferenceActivate?: ( nodeId: string, mediaType: "audio" | "image" | "text" | "video", @@ -353,7 +360,6 @@ interface CanvasChatPanelProps { type CanvasComposerMode = "agent" | "image" | "video" | "detail" | "clawd"; const CLAW_MODE_LABEL = "Claw"; -const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试"; const CANVAS_MODEL_SELECTOR_WIDTH_STORAGE_KEY = "ai-chatbot:canvas-model-selector-width"; const DEFAULT_CANVAS_MODEL_SELECTOR_WIDTH = 360; @@ -371,6 +377,24 @@ function clampCanvasModelSelectorWidth(value: number) { ); } +function requestCanvasLogin( + onRequireLogin: CanvasChatPanelProps["onRequireLogin"], + message: string +) { + if (onRequireLogin) { + onRequireLogin(message); + return; + } + if (typeof window === "undefined") { + return; + } + + const callbackUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.assign( + `/login?callbackUrl=${encodeURIComponent(callbackUrl)}` + ); +} + function getCanvasComposerModeSessionLabel(mode: CanvasComposerMode) { if (mode === "clawd") return CLAW_MODE_LABEL; if (mode === "video") return "视频"; @@ -721,7 +745,7 @@ async function readClawdResponse( streamError = data && typeof data === "object" && "error" in data ? String((data as { error?: unknown }).error || "") - : CLAW_UPGRADE_RETRY_MESSAGE; + : OPENCLAW_UPGRADE_RETRY_MESSAGE; } }; @@ -735,7 +759,7 @@ async function readClawdResponse( } if (buffer.trim()) consumeBlock(buffer); if (streamError) throw new Error(streamError); - if (!finalPayload) throw new Error(CLAW_UPGRADE_RETRY_MESSAGE); + if (!finalPayload) throw new Error(OPENCLAW_UPGRADE_RETRY_MESSAGE); return finalPayload; } @@ -2617,6 +2641,7 @@ export function CanvasChatPanel({ canvasAgentFeatureSettings: propCanvasAgentFeatureSettings, composerReferenceRequest = null, onComposerReferenceHandled, + onRequireLogin, onStoryboardReferenceActivate, }: CanvasChatPanelProps) { const isWorkspacePresentation = presentation === "workspace"; @@ -2629,7 +2654,7 @@ export function CanvasChatPanel({ normalizeCanvasAgentFeatureSettings(propCanvasAgentFeatureSettings) ); const effectiveAgentEnabled = agentEnabled && !isVideoWorkspace; - const { data: session } = useSession(); + const { data: session, status: sessionStatus } = useSession(); const searchParams = useSearchParams(); const requestedChatSessionId = useMemo( () => normalizeCanvasChatSessionId(searchParams.get(CANVAS_CHAT_QUERY_PARAM)), @@ -11615,6 +11640,15 @@ export function CanvasChatPanel({ } if (isClawdMode) { + if (sessionStatus === "loading") { + toast.message("正在确认登录状态,请稍后再试"); + return; + } + if (!session?.user?.id) { + requestCanvasLogin(onRequireLogin, OPENCLAW_AUTH_REQUIRED_MESSAGE); + return; + } + const requestChatId = activeChatId; const requestRawChatId = rawChatId; const isProgrammaticClawdQuickSend = hasOverrideInput; @@ -11861,6 +11895,8 @@ export function CanvasChatPanel({ } let clawdRequestAccepted = false; + let clawdAuthRequired = false; + let clawdFailureMessage = OPENCLAW_UPGRADE_RETRY_MESSAGE; try { const formData = new FormData(); formData.append("id", requestRawChatId); @@ -11930,7 +11966,15 @@ export function CanvasChatPanel({ status: response.status, responseData, }); - throw new Error(CLAW_UPGRADE_RETRY_MESSAGE); + clawdAuthRequired = isOpenClawAuthRequiredError( + response.status, + responseData + ); + clawdFailureMessage = getOpenClawClientErrorMessage( + response.status, + responseData + ); + throw new Error(clawdFailureMessage); } if (clawdSkillTagPrompt && clawdSkillTagForRequest) { @@ -12021,7 +12065,11 @@ export function CanvasChatPanel({ previousMessages.filter((message) => message.id !== userMessageId) ); } - toast.error(CLAW_UPGRADE_RETRY_MESSAGE); + if (clawdAuthRequired) { + requestCanvasLogin(onRequireLogin, OPENCLAW_AUTH_REQUIRED_MESSAGE); + } else { + toast.error(clawdFailureMessage); + } if ( !isProgrammaticClawdQuickSend && activeChatIdRef.current === requestChatId @@ -12512,6 +12560,9 @@ export function CanvasChatPanel({ clearClawdRequestUi, workflowContext, messages, + onRequireLogin, + session?.user?.id, + sessionStatus, ]); const handleOpenClawQuickAction = useCallback( diff --git a/lib/openclaw-chat/errors.ts b/lib/openclaw-chat/errors.ts new file mode 100644 index 000000000..80db4a295 --- /dev/null +++ b/lib/openclaw-chat/errors.ts @@ -0,0 +1,49 @@ +export const OPENCLAW_UPGRADE_RETRY_MESSAGE = + "claw正在升级更新稍等5分钟再试"; +export const OPENCLAW_AUTH_REQUIRED_CODE = "AUTH_REQUIRED"; +export const OPENCLAW_AUTH_REQUIRED_MESSAGE = + "登录状态已失效,请重新登录后使用 Claw"; +export const OPENCLAW_FORBIDDEN_MESSAGE = "当前账号无权访问该 Claw 对话"; + +function readPayloadString(payload: unknown, key: "code" | "error") { + if (!payload || typeof payload !== "object" || !(key in payload)) { + return ""; + } + + const value = (payload as Record)[key]; + return typeof value === "string" ? value.trim() : ""; +} + +export function getOpenClawClientErrorMessage( + status: number, + payload: unknown +) { + const error = readPayloadString(payload, "error"); + + if (isOpenClawAuthRequiredError(status, payload)) { + return OPENCLAW_AUTH_REQUIRED_MESSAGE; + } + + if (status === 403) { + return OPENCLAW_FORBIDDEN_MESSAGE; + } + + if (status >= 400 && status < 500 && status !== 401 && error) { + return error; + } + + return OPENCLAW_UPGRADE_RETRY_MESSAGE; +} + +export function isOpenClawAuthRequiredError( + status: number, + payload: unknown +) { + const code = readPayloadString(payload, "code"); + const error = readPayloadString(payload, "error"); + + return ( + status === 401 && + (code === OPENCLAW_AUTH_REQUIRED_CODE || /^Unauthorized$/i.test(error)) + ); +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 9c13d129d..23c4d29e5 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ /routes\/video-mark-prompt\.test\.ts/, /routes\/video-subtitle-erase-tools\.test\.ts/, /routes\/home-sidebar-auth\.test\.ts/, + /routes\/openclaw-client-errors\.test\.ts/, /routes\/openclaw-video-attachments\.test\.ts/, ], fullyParallel: true, diff --git a/tests/routes/openclaw-client-errors.test.ts b/tests/routes/openclaw-client-errors.test.ts new file mode 100644 index 000000000..28b13894a --- /dev/null +++ b/tests/routes/openclaw-client-errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; +import { + getOpenClawClientErrorMessage, + isOpenClawAuthRequiredError, + OPENCLAW_AUTH_REQUIRED_CODE, + OPENCLAW_AUTH_REQUIRED_MESSAGE, + OPENCLAW_FORBIDDEN_MESSAGE, + OPENCLAW_UPGRADE_RETRY_MESSAGE, +} from "@/lib/openclaw-chat/errors"; + +test.describe("OpenClaw client error messages", () => { + test("reports a signed-out website session as a login error", () => { + const response = { + code: OPENCLAW_AUTH_REQUIRED_CODE, + error: OPENCLAW_AUTH_REQUIRED_MESSAGE, + }; + + expect(getOpenClawClientErrorMessage(401, response)).toBe( + OPENCLAW_AUTH_REQUIRED_MESSAGE + ); + expect(isOpenClawAuthRequiredError(401, response)).toBe(true); + }); + + test("supports the legacy website unauthorized response", () => { + expect( + getOpenClawClientErrorMessage(401, { error: "Unauthorized" }) + ).toBe(OPENCLAW_AUTH_REQUIRED_MESSAGE); + }); + + test("does not confuse an upstream 401 with a signed-out website session", () => { + const response = { + code: "UPSTREAM_AUTH_ERROR", + error: OPENCLAW_UPGRADE_RETRY_MESSAGE, + }; + + expect(getOpenClawClientErrorMessage(401, response)).toBe( + OPENCLAW_UPGRADE_RETRY_MESSAGE + ); + expect(isOpenClawAuthRequiredError(401, response)).toBe(false); + }); + + test("reports a forbidden conversation separately", () => { + expect(getOpenClawClientErrorMessage(403, { error: "Forbidden" })).toBe( + OPENCLAW_FORBIDDEN_MESSAGE + ); + }); + + test("preserves actionable client validation errors", () => { + expect( + getOpenClawClientErrorMessage(400, { error: "请输入问题或上传附件" }) + ).toBe("请输入问题或上传附件"); + }); + + test("uses the upgrade message for service failures", () => { + expect(getOpenClawClientErrorMessage(503, null)).toBe( + OPENCLAW_UPGRADE_RETRY_MESSAGE + ); + }); +});