feat(canvas): add configurable quick skill tags for chat
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-04 00:38:38 +08:00
parent 278418ff7d
commit e93db6b1c7
4 changed files with 379 additions and 34 deletions

View File

@ -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<string[]>([]);
const { data: questionsData, mutate: mutateQuestions } = useSWR<string[]>("/api/starter-questions", fetcher);
const [canvasSkillTags, setCanvasSkillTags] = useState<CanvasSkillTag[]>(
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<AuthSettings>({
dingTalkAppKey: "",
@ -5597,37 +5661,85 @@ export function AdminSettingsClient() {
</TabsContent>
<TabsContent value="questions" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">快捷短语</CardTitle>
<CardDescription>配置聊天输入框下方的预设快捷提问</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{questions.map((q, index) => (
<div key={index} className="flex gap-2">
<Input
value={q}
onChange={(e) => handleQuestionChange(index, e.target.value)}
placeholder={`问题 ${index + 1}`}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveQuestion(index)}
disabled={questions.length <= 1}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
<Button variant="outline" onClick={handleAddQuestion} className="w-full">
<Plus className="mr-2 h-4 w-4" /> 添加短语
</Button>
<Button onClick={handleSaveQuestions} className="w-full md:w-auto">
<Save className="mr-2 h-4 w-4" /> 保存设置
</Button>
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-xl">快捷短语</CardTitle>
<CardDescription>配置聊天输入框下方的预设快捷提问</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{questions.map((q, index) => (
<div key={index} className="flex gap-2">
<Input
value={q}
onChange={(e) => handleQuestionChange(index, e.target.value)}
placeholder={`问题 ${index + 1}`}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveQuestion(index)}
disabled={questions.length <= 1}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
<Button variant="outline" onClick={handleAddQuestion} className="w-full">
<Plus className="mr-2 h-4 w-4" /> 添加短语
</Button>
<Button onClick={handleSaveQuestions} className="w-full md:w-auto">
<Save className="mr-2 h-4 w-4" /> 保存设置
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">画布对话快捷标签</CardTitle>
<CardDescription>
每个标签包含「标签名 + 预设提示词」。用户点击标签后,发送消息时会自动附带对应提示词。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{canvasSkillTags.map((tag, index) => (
<div key={tag.id || index} className="space-y-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Input
value={tag.name}
onChange={(event) =>
handleCanvasSkillTagChange(index, "name", event.target.value)
}
placeholder={`标签名称 ${index + 1}`}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveCanvasSkillTag(index)}
disabled={canvasSkillTags.length <= 1}
>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<Textarea
value={tag.prompt}
onChange={(event) =>
handleCanvasSkillTagChange(index, "prompt", event.target.value)
}
className="min-h-[84px]"
placeholder="请输入该标签对应的预设提示词"
/>
</div>
))}
<Button variant="outline" onClick={handleAddCanvasSkillTag} className="w-full">
<Plus className="mr-2 h-4 w-4" /> 添加标签
</Button>
<Button onClick={handleSaveCanvasSkillTags} className="w-full md:w-auto">
<Save className="mr-2 h-4 w-4" /> 保存标签设置
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
{/* 认证设置 */}

View File

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

View File

@ -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<string>(chatId);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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
</p>
</div>
{/* Suggestions - Removed */}
{canvasSkillTags.length > 0 && (
<div className="mb-8 rounded-3xl border border-[#eceef2] bg-white/90 p-5">
<div className="mb-4 text-center">
<h3 className="text-[17px] font-semibold text-[#1f2937]">试试这些 Skills</h3>
</div>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{canvasSkillTags.map((tag) => {
const isActive = selectedSkillTag?.id === tag.id;
return (
<button
key={tag.id}
type="button"
onClick={() => {
setSelectedSkillTagId(tag.id);
const editor = textareaRef.current as HTMLDivElement | null;
editor?.focus();
}}
className={cn(
"inline-flex items-center justify-center rounded-full border px-3 py-2 text-[15px] font-medium transition-colors",
isActive
? "border-[#6d7fff] bg-[#eef1ff] text-[#3341a9]"
: "border-[#e4e7ec] bg-white text-[#30343b] hover:border-[#d6dae3] hover:bg-[#fafbfc]"
)}
title={tag.prompt}
>
{tag.name}
</button>
);
})}
</div>
</div>
)}
{/* Refresh - Removed */}
@ -7560,6 +7625,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
)}
>
{!isDetailGenerationMode && selectedSkillTag && (
<div className="mb-2 flex">
<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]"
onClick={() => setSelectedSkillTagId(null)}
title="点击移除标签"
>
<MessageCircle className="h-4 w-4 text-[#8b5cf6]" />
<span>{selectedSkillTag.name}</span>
<X className="h-3.5 w-3.5 text-[#9ca3af]" />
</button>
</div>
)}
<div
ref={textareaRef as any}
contentEditable={!isSubmitting}

85
lib/canvas-skill-tags.ts Normal file
View File

@ -0,0 +1,85 @@
export const CANVAS_SKILL_TAGS_SETTING_KEY = "canvas_skill_tags";
export type CanvasSkillTag = {
id: string;
name: string;
prompt: string;
};
export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
{
id: "social-carousel",
name: "社媒轮播图",
prompt:
"你是资深社媒设计师。请输出可直接落地的轮播图方案,包含封面、核心卖点页、场景页与转化页。保持统一视觉风格、品牌色和字体层级,文案精炼且有行动号召。",
},
{
id: "social-media-kit",
name: "社交媒体",
prompt:
"请基于用户需求产出社交媒体视觉方案,优先考虑强识别封面、信息层级清晰和移动端可读性。输出时强调平台适配和可复用模板结构。",
},
{
id: "logo-branding",
name: "Logo 与品牌",
prompt:
"请以品牌设计顾问视角工作:先定义品牌关键词与调性,再给出 Logo 方向、配色、字体与应用场景建议。风格需统一、可执行,避免空泛描述。",
},
{
id: "storyboard",
name: "分镜故事板",
prompt:
"请先构建分镜叙事结构(开场-冲突-转折-收束),再给出每一镜头的画面构图、主体动作、镜头语言和文案要点。保证镜头连续性和视觉一致性。",
},
{
id: "marketing-brochure",
name: "营销宣传册",
prompt:
"请输出可印刷/可投放的宣传册方案,包含封面、核心卖点、案例背书与行动引导。要求信息层级明确、版式节奏均衡、品牌元素统一。",
},
{
id: "ecommerce-main-image",
name: "亚马逊产品套图",
prompt:
"请按电商平台高转化标准输出主图+副图方案,强调产品主体清晰、卖点可视化、场景可信和对比信息明确。保持产品外观一致,不虚构结构细节。",
},
];
function normalizeString(value: unknown) {
return String(value || "").trim();
}
function normalizeId(value: unknown, fallbackIndex: number) {
const raw = normalizeString(value).toLowerCase();
const id = raw
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return id || `tag-${fallbackIndex + 1}`;
}
export function normalizeCanvasSkillTags(value: unknown): CanvasSkillTag[] {
const source = Array.isArray(value) ? value : DEFAULT_CANVAS_SKILL_TAGS;
const usedIds = new Set<string>();
const normalized: CanvasSkillTag[] = [];
source.forEach((item, index) => {
const row = item && typeof item === "object" ? (item as Record<string, unknown>) : {};
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);
}