From e93db6b1c75f96e82233eb686d82c06e68d1105f Mon Sep 17 00:00:00 2001
From: Your Name
Date: Wed, 4 Mar 2026 00:38:38 +0800
Subject: [PATCH] feat(canvas): add configurable quick skill tags for chat
---
.../settings/admin-settings-client.tsx | 174 ++++++++++++++----
app/api/canvas-skill-tags/route.ts | 69 +++++++
components/canvas/CanvasChatPanel.tsx | 85 ++++++++-
lib/canvas-skill-tags.ts | 85 +++++++++
4 files changed, 379 insertions(+), 34 deletions(-)
create mode 100644 app/api/canvas-skill-tags/route.ts
create mode 100644 lib/canvas-skill-tags.ts
diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index 0cda518f2..6665f882b 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -89,11 +89,24 @@ import {
normalizeDulupayConfig,
type DulupayConfig,
} from "@/lib/payment/dulupay-config";
+import {
+ DEFAULT_CANVAS_SKILL_TAGS,
+ normalizeCanvasSkillTags,
+ type CanvasSkillTag,
+} from "@/lib/canvas-skill-tags";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const DEFAULT_RUNWARE_REALTIME_MODEL = "runware:101@1";
const DEFAULT_RUNWARE_STYLIZE_MODEL = "runware:106@1";
+function createEmptyCanvasSkillTag(): CanvasSkillTag {
+ return {
+ id: `tag-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
+ name: "",
+ prompt: "",
+ };
+}
+
// 类型定义
type User = {
id: string;
@@ -982,6 +995,12 @@ export function AdminSettingsClient() {
// 快捷短语状态
const [questions, setQuestions] = useState([]);
const { data: questionsData, mutate: mutateQuestions } = useSWR("/api/starter-questions", fetcher);
+ const [canvasSkillTags, setCanvasSkillTags] = useState(
+ DEFAULT_CANVAS_SKILL_TAGS
+ );
+ const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
+ tags?: CanvasSkillTag[];
+ }>("/api/canvas-skill-tags", fetcher);
// 系统通知状态
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string }>({
@@ -1022,6 +1041,12 @@ export function AdminSettingsClient() {
}
}, [questionsData]);
+ useEffect(() => {
+ if (canvasSkillTagsData) {
+ setCanvasSkillTags(normalizeCanvasSkillTags(canvasSkillTagsData.tags));
+ }
+ }, [canvasSkillTagsData]);
+
const handleQuestionChange = (index: number, value: string) => {
const newQuestions = [...questions];
newQuestions[index] = value;
@@ -1052,6 +1077,45 @@ export function AdminSettingsClient() {
}
};
+ const handleCanvasSkillTagChange = (
+ index: number,
+ field: "name" | "prompt",
+ value: string
+ ) => {
+ setCanvasSkillTags((prev) =>
+ prev.map((item, itemIndex) =>
+ itemIndex === index ? { ...item, [field]: value } : item
+ )
+ );
+ };
+
+ const handleAddCanvasSkillTag = () => {
+ setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
+ };
+
+ const handleRemoveCanvasSkillTag = (index: number) => {
+ setCanvasSkillTags((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
+ };
+
+ const handleSaveCanvasSkillTags = async () => {
+ try {
+ const payload = normalizeCanvasSkillTags(canvasSkillTags);
+ const res = await fetch("/api/canvas-skill-tags", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tags: payload }),
+ });
+ if (!res.ok) throw new Error("Failed");
+
+ const data = await res.json().catch(() => ({}));
+ setCanvasSkillTags(normalizeCanvasSkillTags(data?.tags ?? payload));
+ toast.success("快捷标签已更新");
+ mutateCanvasSkillTags();
+ } catch (error) {
+ toast.error("快捷标签更新失败");
+ }
+ };
+
// 认证设置状态
const [authSettings, setAuthSettings] = useState({
dingTalkAppKey: "",
@@ -5597,37 +5661,85 @@ export function AdminSettingsClient() {
-
-
- 快捷短语
- 配置聊天输入框下方的预设快捷提问
-
-
- {questions.map((q, index) => (
-
- handleQuestionChange(index, e.target.value)}
- placeholder={`问题 ${index + 1}`}
- />
-
-
- ))}
-
-
-
-
+
+
+
+ 快捷短语
+ 配置聊天输入框下方的预设快捷提问
+
+
+ {questions.map((q, index) => (
+
+ handleQuestionChange(index, e.target.value)}
+ placeholder={`问题 ${index + 1}`}
+ />
+
+
+ ))}
+
+
+
+
+
+
+
+ 画布对话快捷标签
+
+ 每个标签包含「标签名 + 预设提示词」。用户点击标签后,发送消息时会自动附带对应提示词。
+
+
+
+ {canvasSkillTags.map((tag, index) => (
+
+
+
+ handleCanvasSkillTagChange(index, "name", event.target.value)
+ }
+ placeholder={`标签名称 ${index + 1}`}
+ />
+
+
+
+ ))}
+
+
+
+
+
{/* 认证设置 */}
diff --git a/app/api/canvas-skill-tags/route.ts b/app/api/canvas-skill-tags/route.ts
new file mode 100644
index 000000000..d34a0d186
--- /dev/null
+++ b/app/api/canvas-skill-tags/route.ts
@@ -0,0 +1,69 @@
+import { NextRequest, NextResponse } from "next/server";
+import { eq } from "drizzle-orm";
+import { auth } from "@/app/(auth)/auth";
+import { db } from "@/lib/db";
+import { systemSettings } from "@/lib/db/schema";
+import {
+ CANVAS_SKILL_TAGS_SETTING_KEY,
+ normalizeCanvasSkillTags,
+} from "@/lib/canvas-skill-tags";
+
+export async function GET() {
+ try {
+ const [setting] = await db
+ .select()
+ .from(systemSettings)
+ .where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
+ .limit(1);
+
+ return NextResponse.json({
+ tags: normalizeCanvasSkillTags(setting?.value),
+ });
+ } catch (error) {
+ console.error("[CanvasSkillTags API] Get error:", error);
+ return NextResponse.json({
+ tags: normalizeCanvasSkillTags(null),
+ });
+ }
+}
+
+export async function PUT(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (session?.user?.type !== "admin") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = await request.json();
+ const rawTags = Array.isArray(body) ? body : body?.tags;
+ const tags = normalizeCanvasSkillTags(rawTags);
+
+ const [existing] = await db
+ .select()
+ .from(systemSettings)
+ .where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
+ .limit(1);
+
+ if (existing) {
+ await db
+ .update(systemSettings)
+ .set({
+ value: tags,
+ updatedAt: new Date(),
+ updatedBy: session.user.id,
+ })
+ .where(eq(systemSettings.id, existing.id));
+ } else {
+ await db.insert(systemSettings).values({
+ key: CANVAS_SKILL_TAGS_SETTING_KEY,
+ value: tags,
+ updatedBy: session.user.id,
+ });
+ }
+
+ return NextResponse.json({ success: true, tags });
+ } catch (error) {
+ console.error("[CanvasSkillTags API] Update error:", error);
+ return NextResponse.json({ error: "Failed to update" }, { status: 500 });
+ }
+}
diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
index de6404abd..b43c9a84d 100644
--- a/components/canvas/CanvasChatPanel.tsx
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -48,6 +48,7 @@ import {
import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { Attachment, ChatMessage } from "@/lib/types";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
+import { normalizeCanvasSkillTags, type CanvasSkillTag } from "@/lib/canvas-skill-tags";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
@@ -1440,6 +1441,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const [activeChatId, setActiveChatId] = useState(chatId);
const [chatSessions, setChatSessions] = useState([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
+ const [selectedSkillTagId, setSelectedSkillTagId] = useState(null);
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef(null);
const fileInputRef = useRef(null);
@@ -1647,6 +1649,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
useEffect(() => {
setTypedAssistantSections({});
setActiveTypingSections({});
+ setSelectedSkillTagId(null);
}, [activeChatId]);
useEffect(() => {
@@ -2246,6 +2249,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
canvasAutoModeDefaultModelId?: string | null;
canvasDetailDefaultModelId?: string | null;
}>("/api/site-settings", fetcher);
+ const { data: canvasSkillTagsData } = useSWR<{ tags?: CanvasSkillTag[] }>(
+ "/api/canvas-skill-tags",
+ fetcher
+ );
const { data: membershipSummary } = useSWR<{
pointsSystemConfig?: {
enabled?: boolean;
@@ -2277,6 +2284,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
? ` 累计为你生成了 ${totalGeneratedImages.toLocaleString()} 张图。`
: "";
const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true;
+ const canvasSkillTags = useMemo(
+ () => normalizeCanvasSkillTags(canvasSkillTagsData?.tags),
+ [canvasSkillTagsData?.tags]
+ );
+ const selectedSkillTag = useMemo(
+ () => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null,
+ [canvasSkillTags, selectedSkillTagId]
+ );
+
+ useEffect(() => {
+ if (!selectedSkillTagId) return;
+ if (!canvasSkillTags.some((tag) => tag.id === selectedSkillTagId)) {
+ setSelectedSkillTagId(null);
+ }
+ }, [canvasSkillTags, selectedSkillTagId]);
const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video"]);
const visualModels = useMemo(() => {
@@ -5368,7 +5390,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return;
}
- if (!input.trim() && attachments.length === 0) return;
+ if (!input.trim() && attachments.length === 0 && !selectedSkillTag) return;
const effectiveModelId = currentModelIdRef.current || selectedModelId;
if (!effectiveModelId) {
toast.error("请选择一个模型");
@@ -5456,9 +5478,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
if (!textContent && effectiveAttachments.length > 0) {
textContent = "请处理这张图片";
}
+ if (!textContent && selectedSkillTag) {
+ textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`;
+ }
// 🆕 隐藏提示词(不在 UI 中展示)
const hiddenSegments: string[] = [];
+ if (selectedSkillTag?.prompt) {
+ hiddenSegments.push(
+ `【快捷标签:${selectedSkillTag.name}】\n${selectedSkillTag.prompt}`
+ );
+ }
if (pickedObjectInfos.length > 0) {
const objectsPrompt = pickedObjectInfos.join('\n');
hiddenSegments.push(
@@ -5503,6 +5533,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
model: effectiveModelId,
text: textContent,
attachments: normalizedAttachmentKeys,
+ skillTagId: selectedSkillTag?.id || null,
mode: isVideoModel ? "video" : "image",
});
@@ -5548,6 +5579,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
activeChatId,
input,
attachments,
+ selectedSkillTag,
selectedModelId,
sendMessage,
clearSelection,
@@ -5930,7 +5962,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const isLoading = status === 'streaming' || status === 'submitted';
const isSubmitting = isLoading || isDetailGenerating;
- const hasInputOrAttachments = Boolean(input.trim() || attachments.length > 0);
+ const hasInputOrAttachments = Boolean(
+ input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
+ );
const detailProductCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
const detailModelReady = DETAIL_MODEL_SLOT_INDEX >= 0 ? Boolean(detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) : false;
const detailBundleCount = DETAIL_BUNDLE_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
@@ -6272,7 +6306,38 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
- {/* Suggestions - Removed */}
+ {canvasSkillTags.length > 0 && (
+
+
+
试试这些 Skills
+
+
+ {canvasSkillTags.map((tag) => {
+ const isActive = selectedSkillTag?.id === tag.id;
+ return (
+
+ );
+ })}
+
+
+ )}
{/* Refresh - Removed */}
@@ -7560,6 +7625,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
)}
>
+ {!isDetailGenerationMode && selectedSkillTag && (
+
+
+
+ )}
();
+ const normalized: CanvasSkillTag[] = [];
+
+ source.forEach((item, index) => {
+ const row = item && typeof item === "object" ? (item as Record) : {};
+ const name = normalizeString(row.name);
+ const prompt = normalizeString(row.prompt);
+ if (!name || !prompt) return;
+
+ let id = normalizeId(row.id, index);
+ while (usedIds.has(id)) {
+ id = `${id}-${index + 1}`;
+ }
+ usedIds.add(id);
+ normalized.push({ id, name, prompt });
+ });
+
+ if (normalized.length === 0) {
+ return DEFAULT_CANVAS_SKILL_TAGS;
+ }
+
+ return normalized.slice(0, 30);
+}