feat: persist claw skill tag order

This commit is contained in:
134 2026-07-03 00:18:52 +08:00
parent b21b4cdea0
commit c698901509
4 changed files with 311 additions and 22 deletions

View File

@ -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<string, unknown>).tagIds
: value;
if (!Array.isArray(source)) return [];
const seen = new Set<string>();
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<string, unknown>).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 });
}
}

View File

@ -133,6 +133,14 @@ function getOpenClawErrorCode(payload: unknown) {
return typeof code === "string" ? code.trim() : "";
}
function getMetadataString(
metadata: Record<string, unknown> | 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 = {

View File

@ -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<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 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<string | null>(null);
const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false);
const [draggingSkillTagId, setDraggingSkillTagId] = useState<string | null>(null);
const pendingSelectedClawdSkillTagRef = useRef<CanvasSkillTag | null>(null);
const draggingSkillTagIdRef = useRef<string | null>(null);
const suppressSkillTagClickRef = useRef(false);
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLButtonElement>, tagId: string) => {
draggingSkillTagIdRef.current = tagId;
setDraggingSkillTagId(tagId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", tagId);
},
[]
);
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;
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({
)}
<span className="truncate">{activeClawdSkillTag.name}</span>
<span className="shrink-0 text-[10px] opacity-70">
{activeClawdSkillTagPromptApplied ? "使用中" : "待带入"}
{activeClawdSkillTagPromptApplied
? "使用中"
: isClawdSubmitting
? "发送中"
: "待带入"}
</span>
</span>
) : 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 (
<button
key={tag.id}
type="button"
draggable={canvasSkillTags.length > 1}
onDragStart={(event) =>
handleCanvasSkillTagDragStart(event, tag.id)
}
onDragOver={(event) =>
handleCanvasSkillTagDragOver(event, tag.id)
}
onDrop={(event) => handleCanvasSkillTagDrop(event, tag.id)}
onDragEnd={handleCanvasSkillTagDragEnd}
onClick={() => handleSelectCanvasSkillTag(tag)}
className={cn(
"inline-flex h-[34px] max-w-full items-center justify-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium leading-none transition-colors",
"inline-flex h-[34px] max-w-full cursor-grab items-center justify-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium leading-none transition-colors 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"
: "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",
isDragging && "scale-95 opacity-45"
)}
title="点击使用,拖动调整顺序"
>
{tag.iconUrl ? (
<img
@ -12939,10 +13084,19 @@ export function CanvasChatPanel({
((shouldTypeAssistantMessage &&
!hasCanvasRenderableMarkdown(plainText)) ||
shouldTypeClawAssistantMessage);
const shouldDeferAssistantMedia =
isClawAssistantMessage &&
(shouldAnimateAnalysis || shouldAnimatePlainText);
const visibleImageUrls = shouldDeferAssistantMedia
? []
: imageUrls;
const visibleVideoUrls = shouldDeferAssistantMedia
? []
: videoUrls;
const shouldShowDoneSummary =
!isDetailGenerationMode &&
!isClawAssistantMessage &&
imageUrls.length > 0 &&
visibleImageUrls.length > 0 &&
!plainText.includes(CANVAS_IMAGE_DONE_SUMMARY) &&
!isLatestAssistantDuringGeneration;
const renderPlainText = (text: string, key: string) => (
@ -13041,7 +13195,7 @@ export function CanvasChatPanel({
onComplete={handleTypewriterComplete}
/>
) : null}
{imageUrls.length > 0 && (
{visibleImageUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages ? "mt-3" : "mt-0")}>
{mediaModelName ? (
<div className="flex items-center gap-1.5 text-[#a4acb8]">
@ -13057,7 +13211,7 @@ export function CanvasChatPanel({
</div>
) : null}
<div className="flex flex-wrap gap-2">
{imageUrls.map((url, idx) => (
{visibleImageUrls.map((url, idx) => (
<div
key={idx}
className={cn(
@ -13135,9 +13289,9 @@ export function CanvasChatPanel({
) : null}
</div>
)}
{videoUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
{videoUrls.map((url, idx) => (
{visibleVideoUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages || visibleImageUrls.length > 0 ? "mt-3" : "mt-0")}>
{visibleVideoUrls.map((url, idx) => (
<div
key={`video-${idx}`}
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2 dark:border-white/10 dark:bg-white/5"
@ -14133,7 +14287,10 @@ export function CanvasChatPanel({
<button
type="button"
className="inline-flex items-center gap-2 rounded-full bg-[#f3f4f6] px-3 py-1.5 text-sm font-medium text-[#4b5563] transition-colors hover:bg-[#e9ebef] dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12"
onClick={() => setSelectedSkillTagId(null)}
onClick={() => {
setSelectedSkillTagId(null);
pendingSelectedClawdSkillTagRef.current = null;
}}
title="点击移除标签"
>
{selectedSkillTag.iconUrl ? (
@ -14176,7 +14333,7 @@ export function CanvasChatPanel({
)}
<span className="truncate">{activeClawdSkillTag.name}</span>
<span className="shrink-0 rounded-full bg-white/70 px-1.5 py-0.5 text-[10px] font-semibold text-violet-500 dark:bg-white/12 dark:text-violet-100">
待发送
{isClawdSubmitting ? "已带入" : "待发送"}
</span>
<button
type="button"
@ -14184,6 +14341,7 @@ export function CanvasChatPanel({
onClick={() => {
setSessionClawdSkillTag(activeChatId, null);
setSelectedSkillTagId(null);
pendingSelectedClawdSkillTagRef.current = null;
}}
disabled={isSubmitting}
aria-label={`移除 ${activeClawdSkillTag.name} 快捷标签`}

View File

@ -1,4 +1,5 @@
export const CANVAS_SKILL_TAGS_SETTING_KEY = "canvas_skill_tags";
export const CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX = "canvas_skill_tag_order:";
export const CANVAS_SKILL_TAGS_CONFIG_VERSION = 4;
export const DEFAULT_CANVAS_SKILL_TAGS_TITLE = "Claw 快捷技能";