From c69890150921aa61518377f4001403deb765b1ca Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 3 Jul 2026 00:18:52 +0800 Subject: [PATCH] feat: persist claw skill tag order --- app/api/canvas-skill-tags/route.ts | 122 +++++++++++++++- app/api/canvas/clawd-chat/route.ts | 16 +++ components/canvas/CanvasChatPanel.tsx | 194 +++++++++++++++++++++++--- lib/canvas-skill-tags.ts | 1 + 4 files changed, 311 insertions(+), 22 deletions(-) diff --git a/app/api/canvas-skill-tags/route.ts b/app/api/canvas-skill-tags/route.ts index 11f68b249..22c9ec3c0 100644 --- a/app/api/canvas-skill-tags/route.ts +++ b/app/api/canvas-skill-tags/route.ts @@ -4,26 +4,140 @@ import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { systemSettings } from "@/lib/db/schema"; 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"; +function getUserSkillTagOrderKey(userId: string) { + return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`; +} + +function normalizeSkillTagOrder(value: unknown) { + const source = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record).tagIds + : value; + if (!Array.isArray(source)) return []; + + const seen = new Set(); + return source + .map((item) => String(item || "").trim()) + .filter((item) => { + if (!item || seen.has(item)) return false; + seen.add(item); + return true; + }); +} + +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); + const userId = session?.user?.id || null; + const [setting, userOrderSetting] = await Promise.all([ + db + .select() + .from(systemSettings) + .where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY)) + .limit(1) + .then((rows) => rows[0]), + userId + ? db + .select() + .from(systemSettings) + .where(eq(systemSettings.key, getUserSkillTagOrderKey(userId))) + .limit(1) + .then((rows) => rows[0]) + : Promise.resolve(null), + ]); + + const settings = normalizeCanvasSkillTagSettings(setting?.value, { + requireCurrentVersion: true, + }); + const userOrder = normalizeSkillTagOrder(userOrderSetting?.value); + return NextResponse.json({ + ...settings, + tags: applySkillTagOrder(settings.tags, userOrder), + userOrder, + }); + } catch (error) { + console.error("[CanvasSkillTags API] Get error:", error); + return NextResponse.json(normalizeCanvasSkillTagSettings(null)); + } +} + +export async function PATCH(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => null); + const requestedOrder = normalizeSkillTagOrder( + body && typeof body === "object" ? (body as Record).tagIds : body + ); + const [setting] = await db .select() .from(systemSettings) .where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY)) .limit(1); - const settings = normalizeCanvasSkillTagSettings(setting?.value, { requireCurrentVersion: true, }); - return NextResponse.json(settings); + const knownIds = new Set(settings.tags.map((tag) => tag.id)); + const orderedKnownIds = requestedOrder.filter((id) => knownIds.has(id)); + const orderedSet = new Set(orderedKnownIds); + const tagIds = [ + ...orderedKnownIds, + ...settings.tags.map((tag) => tag.id).filter((id) => !orderedSet.has(id)), + ]; + const value = { + version: 1, + tagIds, + }; + const key = getUserSkillTagOrderKey(session.user.id); + + await db + .insert(systemSettings) + .values({ + key, + value, + updatedBy: session.user.id, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: systemSettings.key, + set: { + value, + updatedBy: session.user.id, + updatedAt: new Date(), + }, + }); + + return NextResponse.json({ + success: true, + userOrder: tagIds, + tags: applySkillTagOrder(settings.tags, tagIds), + }); } catch (error) { - console.error("[CanvasSkillTags API] Get error:", error); - return NextResponse.json(normalizeCanvasSkillTagSettings(null)); + console.error("[CanvasSkillTags API] Save order error:", error); + return NextResponse.json({ error: "Failed to save order" }, { status: 500 }); } } diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 9df44252c..354dd11d7 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -133,6 +133,14 @@ function getOpenClawErrorCode(payload: unknown) { return typeof code === "string" ? code.trim() : ""; } +function getMetadataString( + metadata: Record | undefined, + key: string +) { + const value = metadata?.[key]; + return typeof value === "string" ? value.trim() : ""; +} + export async function POST(request: Request) { const session = await auth().catch(() => null); @@ -211,6 +219,8 @@ export async function POST(request: Request) { email: userProfile?.email ?? session.user.email, }); const openClawDepartmentName = getOpenClawDepartmentName(userProfile?.department); + const skillTagId = getMetadataString(parsed.metadata, "skillTagId"); + const skillTagName = getMetadataString(parsed.metadata, "skillTagName"); const endpoint = `${config.baseUrl}/api/chat`; let upstreamPayload: unknown = null; @@ -233,6 +243,8 @@ export async function POST(request: Request) { department: openClawDepartmentName, department_name: openClawDepartmentName, user_department: openClawDepartmentName, + skill_tag_id: skillTagId || undefined, + skill_tag_name: skillTagName || undefined, message: upstreamMessage, metadata: { source: "ai_chatbot_canvas", @@ -245,6 +257,8 @@ export async function POST(request: Request) { department: openClawDepartmentName, department_name: openClawDepartmentName, user_department: openClawDepartmentName, + skill_tag_id: skillTagId || undefined, + skill_tag_name: skillTagName || undefined, spreadsheet_files: spreadsheetMarkdownResult?.files || undefined, spreadsheet_markdown_truncated: spreadsheetMarkdownResult?.truncated || undefined, @@ -326,6 +340,8 @@ export async function POST(request: Request) { videoMetadata: { composerMode: "clawd", source: "openclaw", + skillTagId: skillTagId || null, + skillTagName: skillTagName || null, }, }; const assistantMessage = { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index a03975983..4070b7f0c 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -2,7 +2,7 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; -import { useEffect, useRef, useState, useCallback, useMemo, type ClipboardEvent as ReactClipboardEvent, type MouseEvent as ReactMouseEvent } from "react"; +import { useEffect, useRef, useState, useCallback, useMemo, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent } from "react"; import { useSession } from "next-auth/react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { createPortal } from "react-dom"; @@ -418,6 +418,24 @@ function buildClawdSkillTagPrompt(tag: Pick | ].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 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; +} + type ClawdComposerSubmissionSnapshot = { chatId: string; canvasId?: string; @@ -2166,6 +2184,10 @@ export function CanvasChatPanel({ const [isComposerModeMenuOpen, setIsComposerModeMenuOpen] = useState(false); const [selectedSkillTagId, setSelectedSkillTagId] = useState(null); const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false); + const [draggingSkillTagId, setDraggingSkillTagId] = useState(null); + const pendingSelectedClawdSkillTagRef = useRef(null); + const draggingSkillTagIdRef = useRef(null); + const suppressSkillTagClickRef = useRef(false); const [messageViewportKey, setMessageViewportKey] = useState(0); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); @@ -3948,6 +3970,7 @@ export function CanvasChatPanel({ enabled?: boolean; title?: string; tags?: CanvasSkillTag[]; + userOrder?: string[]; }>( "/api/canvas-skill-tags", fetcher, @@ -4136,6 +4159,42 @@ export function CanvasChatPanel({ () => (canvasSkillTagsEnabled ? normalizeCanvasSkillTags(canvasSkillTagsData?.tags) : []), [canvasSkillTagsData?.tags, canvasSkillTagsEnabled] ); + const handleReorderCanvasSkillTag = useCallback( + async (draggedId: string, targetId: string) => { + const nextTags = reorderCanvasSkillTags(canvasSkillTags, draggedId, targetId); + if (nextTags === canvasSkillTags) return; + + const userOrder = nextTags.map((tag) => tag.id); + const optimisticData = { + ...(canvasSkillTagsData || {}), + enabled: canvasSkillTagsData?.enabled, + title: canvasSkillTagsData?.title, + tags: nextTags, + userOrder, + }; + void mutate("/api/canvas-skill-tags", optimisticData, false); + + try { + const response = await fetch("/api/canvas-skill-tags", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ tagIds: userOrder }), + }); + if (!response.ok) { + throw new Error("Failed to save skill tag order"); + } + void mutate("/api/canvas-skill-tags"); + toast.success("快捷标签顺序已保存"); + } catch (error) { + console.error("[CanvasChat] 保存快捷标签顺序失败:", error); + toast.error("快捷标签顺序保存失败"); + void mutate("/api/canvas-skill-tags"); + } + }, + [canvasSkillTags, canvasSkillTagsData, mutate] + ); const selectedSkillTag = useMemo( () => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null, [canvasSkillTags, selectedSkillTagId] @@ -4177,13 +4236,19 @@ export function CanvasChatPanel({ if (!selectedSkillTagId) return; if (!canvasSkillTags.some((tag) => tag.id === selectedSkillTagId)) { setSelectedSkillTagId(null); + pendingSelectedClawdSkillTagRef.current = null; } }, [canvasSkillTags, selectedSkillTagId]); + useEffect(() => { + pendingSelectedClawdSkillTagRef.current = selectedSkillTag; + }, [selectedSkillTag]); + useEffect(() => { if (!isWorkspacePresentation) return; if (!selectedSkillTagId) return; setSelectedSkillTagId(null); + pendingSelectedClawdSkillTagRef.current = null; }, [isWorkspacePresentation, selectedSkillTagId]); const visualModels = useMemo(() => { @@ -5314,6 +5379,9 @@ export function CanvasChatPanel({ const handleSelectCanvasSkillTag = useCallback( (tag: CanvasSkillTag) => { + if (suppressSkillTagClickRef.current) { + return; + } if (!isClawdModeEnabled) { toast.error(`${CLAW_MODE_LABEL} 未开启`); return; @@ -5331,6 +5399,7 @@ export function CanvasChatPanel({ setSessionClawdSkillTag(targetChatSessionId, tag.id, { promptApplied: false, }); + pendingSelectedClawdSkillTagRef.current = tag; window.setTimeout(() => { setSelectedSkillTagId(tag.id); const editor = textareaRef.current as HTMLDivElement | null; @@ -5348,6 +5417,49 @@ export function CanvasChatPanel({ ] ); + const handleCanvasSkillTagDragStart = useCallback( + (event: ReactDragEvent, tagId: string) => { + draggingSkillTagIdRef.current = tagId; + setDraggingSkillTagId(tagId); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", tagId); + }, + [] + ); + + const handleCanvasSkillTagDragOver = useCallback( + (event: ReactDragEvent, targetId: string) => { + const draggedId = draggingSkillTagIdRef.current; + if (!draggedId || draggedId === targetId) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }, + [] + ); + + const handleCanvasSkillTagDrop = useCallback( + (event: ReactDragEvent, targetId: string) => { + event.preventDefault(); + const draggedId = + event.dataTransfer.getData("text/plain") || draggingSkillTagIdRef.current; + draggingSkillTagIdRef.current = null; + setDraggingSkillTagId(null); + if (!draggedId || draggedId === targetId) return; + + suppressSkillTagClickRef.current = true; + window.setTimeout(() => { + suppressSkillTagClickRef.current = false; + }, 0); + void handleReorderCanvasSkillTag(draggedId, targetId); + }, + [handleReorderCanvasSkillTag] + ); + + const handleCanvasSkillTagDragEnd = useCallback(() => { + draggingSkillTagIdRef.current = null; + setDraggingSkillTagId(null); + }, []); + useEffect(() => { if (!effectiveAgentEnabled || typeof window === "undefined") return; const targetWindow = window as CanvasAgentPendingRequestWindow; @@ -10191,13 +10303,18 @@ export function CanvasChatPanel({ const textContent = normalizeClawdComposerText(composerInput); const tableFilesSnapshot = isProgrammaticClawdQuickSend ? [] : [...clawdTableFiles]; const hasTableFiles = tableFilesSnapshot.length > 0; + const pendingSelectedClawdSkillTag = pendingSelectedClawdSkillTagRef.current; const clawdSkillTagForRequest = isProgrammaticClawdQuickSend ? activeClawdSkillTag - : selectedSkillTag || activeClawdSkillTag; + : selectedSkillTag || pendingSelectedClawdSkillTag || activeClawdSkillTag; + const hasFreshClawdSkillTagSelection = Boolean( + !isProgrammaticClawdQuickSend && + (selectedSkillTag || pendingSelectedClawdSkillTag) + ); const shouldIncludeClawdSkillTagPrompt = Boolean( !isProgrammaticClawdQuickSend && clawdSkillTagForRequest && - (selectedSkillTag || !activeClawdSkillTagPromptApplied) + (hasFreshClawdSkillTagSelection || !activeClawdSkillTagPromptApplied) ); const clawdSkillTagPrompt = shouldIncludeClawdSkillTagPrompt ? buildClawdSkillTagPrompt(clawdSkillTagForRequest) @@ -10234,10 +10351,17 @@ export function CanvasChatPanel({ .filter(Boolean) .join("\n\n"); const optimisticBaseMessage = - textContent || - (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt - ? `快捷功能:${clawdSkillTagForRequest.name}` - : baseRequestMessage); + [ + clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt + ? `快捷功能:${clawdSkillTagForRequest.name}` + : "", + textContent || + (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt + ? "" + : baseRequestMessage), + ] + .filter(Boolean) + .join("\n") || baseRequestMessage; const optimisticMessageText = [ buildClawdTableDisplayText(optimisticBaseMessage, tableFilesSnapshot), buildClawdImageDisplayText(imageAttachmentsSnapshot), @@ -10341,6 +10465,7 @@ export function CanvasChatPanel({ setAttachments([]); setClawdTableFiles([]); setSelectedSkillTagId(null); + pendingSelectedClawdSkillTagRef.current = null; clearSelection(); } @@ -10356,6 +10481,9 @@ export function CanvasChatPanel({ composerMode: "clawd", activeChatId, imageAttachmentCount: imageAttachmentsSnapshot.length || undefined, + skillTagId: clawdSkillTagForRequest?.id || undefined, + skillTagName: clawdSkillTagForRequest?.name || undefined, + skillTagPromptIncluded: hasPendingClawdSkillTagPrompt || undefined, }) ); tableFilesSnapshot.forEach((file) => { @@ -10430,6 +10558,7 @@ export function CanvasChatPanel({ setAttachments(attachmentsSnapshot); if (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt) { setSelectedSkillTagId(clawdSkillTagForRequest.id); + pendingSelectedClawdSkillTagRef.current = clawdSkillTagForRequest; } const editableDiv = textareaRef.current as HTMLDivElement | null; const currentEditorText = (editableDiv?.textContent || "") @@ -12305,7 +12434,11 @@ export function CanvasChatPanel({ )} {activeClawdSkillTag.name} - {activeClawdSkillTagPromptApplied ? "使用中" : "待带入"} + {activeClawdSkillTagPromptApplied + ? "使用中" + : isClawdSubmitting + ? "发送中" + : "待带入"} ) : null} @@ -12604,18 +12737,30 @@ export function CanvasChatPanel({ > {canvasSkillTags.map((tag) => { const isActive = selectedSkillTag?.id === tag.id; + const isDragging = draggingSkillTagId === tag.id; const TagIcon = getCanvasSkillIconComponent(tag.iconName); return (