fix(canvas): handle expired sessions for Claw skills

This commit is contained in:
134 2026-08-05 00:14:06 +08:00
parent 233c7b3308
commit c6c7c703b5
11 changed files with 392 additions and 126 deletions

View File

@ -7,9 +7,9 @@ import {
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
CANVAS_SKILL_TAGS_CONFIG_VERSION,
CANVAS_SKILL_TAGS_SETTING_KEY,
type CanvasSkillTag,
normalizeCanvasSkillTagSettings,
} from "@/lib/canvas-skill-tags";
import { applyCanvasSkillTagOrder } from "@/lib/canvas-skill-tag-order";
function getUserSkillTagOrderKey(userId: string) {
return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`;
@ -32,18 +32,6 @@ function normalizeSkillTagOrder(value: unknown) {
});
}
function applySkillTagOrder(tags: CanvasSkillTag[], order: string[]) {
if (tags.length < 2 || order.length === 0) return tags;
const tagById = new Map(tags.map((tag) => [tag.id, tag]));
const ordered = order
.map((id) => tagById.get(id))
.filter((tag): tag is CanvasSkillTag => Boolean(tag));
const orderedIds = new Set(ordered.map((tag) => tag.id));
const remaining = tags.filter((tag) => !orderedIds.has(tag.id));
return [...ordered, ...remaining];
}
export async function GET() {
try {
const session = await auth().catch(() => null);
@ -71,7 +59,7 @@ export async function GET() {
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
return NextResponse.json({
...settings,
tags: applySkillTagOrder(settings.tags, userOrder),
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
userOrder,
});
} catch (error) {
@ -133,7 +121,7 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({
success: true,
userOrder: tagIds,
tags: applySkillTagOrder(settings.tags, tagIds),
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
});
} catch (error) {
console.error("[CanvasSkillTags API] Save order error:", error);

View File

@ -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));

View File

@ -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);

View File

@ -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,
clearClawdRequestCancellation,
@ -32,8 +37,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(""),
@ -364,7 +367,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);
@ -384,11 +393,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 }
);
}
@ -599,7 +611,7 @@ export async function POST(request: Request) {
streamController.enqueue(
encoder.encode(
encodeSseEvent("error", {
error: CLAW_UPGRADE_RETRY_MESSAGE,
error: OPENCLAW_UPGRADE_RETRY_MESSAGE,
})
)
);
@ -610,7 +622,7 @@ export async function POST(request: Request) {
streamController.enqueue(
encoder.encode(
encodeSseEvent("error", {
error: CLAW_UPGRADE_RETRY_MESSAGE,
error: OPENCLAW_UPGRADE_RETRY_MESSAGE,
})
)
);
@ -645,7 +657,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 }
@ -668,7 +680,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 }
);
@ -682,7 +694,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 }
);
}

View File

@ -10868,6 +10868,7 @@ export default function CanvasEditorLegacyPage({
chatScope="canvas"
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode("fileList")}
onRequireLogin={openLoginDialog}
selectedAgentPresetId={
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
}

View File

@ -2,7 +2,7 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo, lazy, Suspense, type ClipboardEvent as ReactClipboardEvent, type ComponentProps, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useEffect, useRef, useState, useCallback, useMemo, lazy, Suspense, type ClipboardEvent as ReactClipboardEvent, type ComponentProps, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { createPortal } from "react-dom";
@ -101,10 +101,20 @@ import {
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import {
applyCanvasSkillTagOrder,
reorderCanvasSkillTags,
} from "@/lib/canvas-skill-tag-order";
import {
isClawdTransportInterruption,
shouldRestoreClawdComposerAfterFailure,
} from "@/lib/openclaw-chat/request-lifecycle";
import {
getOpenClawClientErrorMessage,
isOpenClawAuthRequiredError,
OPENCLAW_AUTH_REQUIRED_MESSAGE,
OPENCLAW_UPGRADE_RETRY_MESSAGE,
} from "@/lib/openclaw-chat/errors";
import {
isCanvasImageReadyForAi,
isCanvasImageUploadPending,
@ -368,12 +378,12 @@ interface CanvasChatPanelProps {
workspaceVariant?: "canvas" | "video";
canvasToolbarConfig?: CanvasToolbarConfig;
canvasAgentFeatureSettings?: CanvasAgentFeatureSettings;
onRequireLogin?: (message?: string) => void;
}
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;
@ -391,6 +401,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 "视频";
@ -510,34 +538,15 @@ function buildClawdSkillTagPrompt(tag: Pick<CanvasSkillTag, "name" | "prompt"> |
].join("\n");
}
function reorderCanvasSkillTags(
tags: CanvasSkillTag[],
draggedId: string,
targetId: string
) {
if (!draggedId || !targetId || draggedId === targetId) return tags;
const currentIndex = tags.findIndex((tag) => tag.id === draggedId);
const targetIndex = tags.findIndex((tag) => tag.id === targetId);
if (currentIndex < 0 || targetIndex < 0) return tags;
const CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX = 6;
const nextTags = [...tags];
const [draggedTag] = nextTags.splice(currentIndex, 1);
if (!draggedTag) return tags;
const nextTargetIndex = nextTags.findIndex((tag) => tag.id === targetId);
nextTags.splice(nextTargetIndex < 0 ? targetIndex : nextTargetIndex, 0, draggedTag);
return nextTags;
}
function applyCanvasSkillTagOrder(tags: CanvasSkillTag[], order: string[]) {
if (tags.length < 2 || order.length === 0) return tags;
const tagById = new Map(tags.map((tag) => [tag.id, tag]));
const orderedTags = order
.map((id) => tagById.get(id))
.filter((tag): tag is CanvasSkillTag => Boolean(tag));
const orderedIds = new Set(orderedTags.map((tag) => tag.id));
return [...orderedTags, ...tags.filter((tag) => !orderedIds.has(tag.id))];
}
type CanvasSkillTagPointerDragState = {
pointerId: number;
tagId: string;
startX: number;
startY: number;
moved: boolean;
};
type ClawdComposerSubmissionSnapshot = {
chatId: string;
@ -698,7 +707,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;
}
};
@ -712,7 +721,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;
}
@ -2556,6 +2565,7 @@ export function CanvasChatPanel({
workspaceVariant = "canvas",
canvasToolbarConfig: rawCanvasToolbarConfig,
canvasAgentFeatureSettings: propCanvasAgentFeatureSettings,
onRequireLogin,
}: CanvasChatPanelProps) {
const isWorkspacePresentation = presentation === "workspace";
const isVideoWorkspace = workspaceVariant === "video";
@ -2567,7 +2577,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)),
@ -2662,7 +2672,9 @@ export function CanvasChatPanel({
null
);
const pendingSelectedClawdSkillTagRef = useRef<CanvasSkillTag | null>(null);
const draggingSkillTagIdRef = useRef<string | null>(null);
const canvasSkillTagPointerDragRef = useRef<CanvasSkillTagPointerDragState | null>(
null
);
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
const suppressSkillTagClickRef = useRef(false);
@ -4871,6 +4883,12 @@ export function CanvasChatPanel({
},
body: JSON.stringify({ tagIds: userOrder }),
});
if (response.status === 401) {
setCanvasSkillTagOrderOverride(null);
requestCanvasLogin(onRequireLogin, OPENCLAW_AUTH_REQUIRED_MESSAGE);
void mutate("/api/canvas-skill-tags");
return;
}
if (!response.ok) {
throw new Error("Failed to save skill tag order");
}
@ -4883,7 +4901,7 @@ export function CanvasChatPanel({
void mutate("/api/canvas-skill-tags");
}
},
[baseCanvasSkillTags, canvasSkillTagsData, mutate]
[baseCanvasSkillTags, canvasSkillTagsData, mutate, onRequireLogin]
);
const selectedSkillTag = useMemo(
() => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null,
@ -6208,23 +6226,9 @@ export function CanvasChatPanel({
]
);
const handleCanvasSkillTagDragStart = useCallback(
(event: ReactDragEvent<HTMLButtonElement>, tagId: string) => {
draggingSkillTagIdRef.current = tagId;
pendingCanvasSkillTagOrderSaveRef.current = null;
setDraggingSkillTagId(tagId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", tagId);
},
[]
);
const handleCanvasSkillTagDragEnter = useCallback(
(event: ReactDragEvent<HTMLButtonElement>, targetId: string) => {
const draggedId = draggingSkillTagIdRef.current;
const updateCanvasSkillTagPointerOrder = useCallback(
(draggedId: string, targetId: string) => {
if (!draggedId || draggedId === targetId) return;
event.preventDefault();
const currentTags = latestCanvasSkillTagsRef.current;
const nextTags = reorderCanvasSkillTags(currentTags, draggedId, targetId);
if (nextTags === currentTags) return;
@ -6237,53 +6241,88 @@ export function CanvasChatPanel({
[]
);
const handleCanvasSkillTagDragOver = useCallback(
(event: ReactDragEvent<HTMLButtonElement>, targetId: string) => {
const draggedId = draggingSkillTagIdRef.current;
if (!draggedId || draggedId === targetId) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
},
[]
);
const handleCanvasSkillTagDrop = useCallback(
(event: ReactDragEvent<HTMLButtonElement>, targetId: string) => {
event.preventDefault();
const draggedId =
event.dataTransfer.getData("text/plain") || draggingSkillTagIdRef.current;
if (draggedId && draggedId !== targetId && !pendingCanvasSkillTagOrderSaveRef.current) {
const nextTags = reorderCanvasSkillTags(
latestCanvasSkillTagsRef.current,
draggedId,
targetId
);
if (nextTags !== latestCanvasSkillTagsRef.current) {
const nextOrder = nextTags.map((tag) => tag.id);
pendingCanvasSkillTagOrderSaveRef.current = nextOrder;
latestCanvasSkillTagsRef.current = nextTags;
setCanvasSkillTagOrderOverride(nextOrder);
const handleCanvasSkillTagPointerDown = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>, tagId: string) => {
if (
isCanvasSkillTagSearching ||
canvasSkillTags.length < 2 ||
event.button !== 0
) {
return;
}
if (sessionStatus === "loading") {
return;
}
if (!session?.user?.id) {
requestCanvasLogin(onRequireLogin, OPENCLAW_AUTH_REQUIRED_MESSAGE);
return;
}
suppressSkillTagClickRef.current = true;
window.setTimeout(() => {
suppressSkillTagClickRef.current = false;
}, 0);
canvasSkillTagPointerDragRef.current = {
pointerId: event.pointerId,
tagId,
startX: event.clientX,
startY: event.clientY,
moved: false,
};
pendingCanvasSkillTagOrderSaveRef.current = null;
event.currentTarget.setPointerCapture(event.pointerId);
},
[]
[
canvasSkillTags.length,
isCanvasSkillTagSearching,
onRequireLogin,
session?.user?.id,
sessionStatus,
]
);
const handleCanvasSkillTagDragEnd = useCallback(() => {
const handleCanvasSkillTagPointerMove = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) => {
const dragState = canvasSkillTagPointerDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
if (!dragState.moved) {
const distance = Math.hypot(
event.clientX - dragState.startX,
event.clientY - dragState.startY
);
if (distance < CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX) return;
dragState.moved = true;
setDraggingSkillTagId(dragState.tagId);
}
event.preventDefault();
const target = document
.elementFromPoint(event.clientX, event.clientY)
?.closest<HTMLElement>("[data-canvas-skill-tag-id]");
const targetId = String(target?.dataset.canvasSkillTagId || "").trim();
updateCanvasSkillTagPointerOrder(dragState.tagId, targetId);
},
[updateCanvasSkillTagPointerOrder]
);
const handleCanvasSkillTagPointerEnd = useCallback((
event: ReactPointerEvent<HTMLButtonElement>
) => {
const dragState = canvasSkillTagPointerDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
const pendingOrder = pendingCanvasSkillTagOrderSaveRef.current;
pendingCanvasSkillTagOrderSaveRef.current = null;
draggingSkillTagIdRef.current = null;
canvasSkillTagPointerDragRef.current = null;
setDraggingSkillTagId(null);
if (pendingOrder?.length) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (dragState.moved) {
suppressSkillTagClickRef.current = true;
window.setTimeout(() => {
suppressSkillTagClickRef.current = false;
}, 0);
}
if (dragState.moved && pendingOrder?.length) {
void persistCanvasSkillTagOrder(pendingOrder);
}
}, [persistCanvasSkillTagOrder]);
@ -11309,6 +11348,15 @@ export function CanvasChatPanel({
}
if (isClawdMode) {
if (sessionStatus === "loading") {
toast.message("正在确认登录状态,请稍后再试");
return;
}
if (!session?.user?.id) {
requestCanvasLogin(onRequireLogin, OPENCLAW_AUTH_REQUIRED_MESSAGE);
return;
}
const isProgrammaticClawdQuickSend = hasOverrideInput;
const textContent = normalizeClawdComposerText(composerInput);
const tableFilesSnapshot = isProgrammaticClawdQuickSend ? [] : [...clawdTableFiles];
@ -11547,6 +11595,8 @@ export function CanvasChatPanel({
clearSelection();
}
let clawdAuthRequired = false;
let clawdFailureMessage = OPENCLAW_UPGRADE_RETRY_MESSAGE;
try {
const formData = new FormData();
formData.append("id", rawChatId);
@ -11596,7 +11646,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) {
@ -11655,10 +11713,21 @@ export function CanvasChatPanel({
return;
}
console.error("[CanvasChat] Claw request failed:", error);
if (clawdAuthRequired) {
setMessages((previousMessages) =>
previousMessages.filter((message) => message.id !== userMessageId)
);
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
pendingClawdRequestRestoreRef.current = null;
restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot);
}
requestCanvasLogin(onRequireLogin, OPENCLAW_AUTH_REQUIRED_MESSAGE);
return;
}
toast.error(
isClawdTransportInterruption(error)
? "连接已中断,任务仍可能在后台处理,请稍后查看 Claw 历史"
: CLAW_UPGRADE_RETRY_MESSAGE
: clawdFailureMessage
);
} finally {
pendingClawdSubmissionRef.current = null;
@ -12071,6 +12140,9 @@ export function CanvasChatPanel({
restorePendingClawdRequestToComposer,
workflowContext,
messages,
onRequireLogin,
session?.user?.id,
sessionStatus,
]);
const handleOpenClawQuickAction = useCallback(
@ -14454,24 +14526,19 @@ export function CanvasChatPanel({
<button
key={tag.id}
type="button"
draggable={!isCanvasSkillTagSearching && canvasSkillTags.length > 1}
onDragStart={(event) =>
handleCanvasSkillTagDragStart(event, tag.id)
data-canvas-skill-tag-id={tag.id}
onPointerDown={(event) =>
handleCanvasSkillTagPointerDown(event, tag.id)
}
onDragEnter={(event) =>
handleCanvasSkillTagDragEnter(event, tag.id)
}
onDragOver={(event) =>
handleCanvasSkillTagDragOver(event, tag.id)
}
onDrop={(event) => handleCanvasSkillTagDrop(event, tag.id)}
onDragEnd={handleCanvasSkillTagDragEnd}
onPointerMove={handleCanvasSkillTagPointerMove}
onPointerUp={handleCanvasSkillTagPointerEnd}
onPointerCancel={handleCanvasSkillTagPointerEnd}
onClick={() => handleSelectCanvasSkillTag(tag)}
className={cn(
"inline-flex h-8 max-w-full items-center justify-center gap-1.5 rounded-full border px-3 text-[12px] font-medium leading-none transition-colors duration-150",
isCanvasSkillTagSearching
? "cursor-pointer"
: "cursor-grab active:cursor-grabbing",
: "touch-none select-none cursor-grab active:cursor-grabbing",
isActive
? "border-[#d8dce4] bg-[#f7f8fa] text-[#1f2328] dark:border-blue-400/40 dark:bg-blue-500/15 dark:text-blue-200"
: "border-[#e5e7eb] bg-white text-[#1f2328] hover:border-[#d6dae3] hover:bg-[#fafbfc] dark:border-white/10 dark:bg-white/5 dark:text-zinc-200 dark:hover:border-white/20 dark:hover:bg-white/10",
@ -14487,6 +14554,7 @@ export function CanvasChatPanel({
<img
src={buildPreviewImageUrl(tag.iconUrl, 40)}
alt={tag.name}
draggable={false}
className="h-4 w-4 rounded-sm object-cover"
loading="lazy"
decoding="async"

View File

@ -0,0 +1,35 @@
type OrderedItem = {
id: string;
};
export function reorderCanvasSkillTags<T extends OrderedItem>(
tags: T[],
draggedId: string,
targetId: string
) {
if (!draggedId || !targetId || draggedId === targetId) return tags;
const currentIndex = tags.findIndex((tag) => tag.id === draggedId);
const targetIndex = tags.findIndex((tag) => tag.id === targetId);
if (currentIndex < 0 || targetIndex < 0) return tags;
const nextTags = [...tags];
const [draggedTag] = nextTags.splice(currentIndex, 1);
if (!draggedTag) return tags;
const nextTargetIndex = nextTags.findIndex((tag) => tag.id === targetId);
nextTags.splice(nextTargetIndex < 0 ? targetIndex : nextTargetIndex, 0, draggedTag);
return nextTags;
}
export function applyCanvasSkillTagOrder<T extends OrderedItem>(
tags: T[],
order: string[]
) {
if (tags.length < 2 || order.length === 0) return tags;
const tagById = new Map(tags.map((tag) => [tag.id, tag]));
const orderedTags = order
.map((id) => tagById.get(id))
.filter((tag): tag is T => Boolean(tag));
const orderedIds = new Set(orderedTags.map((tag) => tag.id));
return [...orderedTags, ...tags.filter((tag) => !orderedIds.has(tag.id))];
}

View File

@ -0,0 +1,45 @@
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<string, unknown>)[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))
);
}

View File

@ -14,6 +14,8 @@ export default defineConfig({
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
/routes\/model-classification\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/,
/routes\/canvas-skill-tag-order\.test\.ts/,
/routes\/openclaw-client-errors\.test\.ts/,
/routes\/openclaw-video-attachments\.test\.ts/,
/routes\/hicad-embedded-performance\.test\.ts/,
/routes\/hicad-manufacturing-extension\.test\.ts/,

View File

@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
import {
applyCanvasSkillTagOrder,
reorderCanvasSkillTags,
} from "@/lib/canvas-skill-tag-order";
const tags = [
{ id: "video", name: "视频理解" },
{ id: "image", name: "以图搜图" },
{ id: "market", name: "市场大盘分析" },
{ id: "stock", name: "库存查询" },
];
test.describe("canvas skill tag ordering", () => {
test("moves a later skill before an earlier target", () => {
expect(
reorderCanvasSkillTags(tags, "stock", "image").map((tag) => tag.id)
).toEqual(["video", "stock", "image", "market"]);
});
test("moves an earlier skill before a later target", () => {
expect(
reorderCanvasSkillTags(tags, "video", "stock").map((tag) => tag.id)
).toEqual(["image", "market", "video", "stock"]);
});
test("keeps newly configured skills after a saved partial order", () => {
expect(
applyCanvasSkillTagOrder(tags, ["market", "video"]).map((tag) => tag.id)
).toEqual(["market", "video", "image", "stock"]);
});
test("ignores unknown ids in a saved order", () => {
expect(
applyCanvasSkillTagOrder(tags, ["missing", "image"]).map((tag) => tag.id)
).toEqual(["image", "video", "market", "stock"]);
});
});

View File

@ -0,0 +1,57 @@
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
);
});
});