diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 25f996220..fdf63d42a 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -11,7 +11,7 @@ import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; import { WorkingGlow } from "@/components/ai/working-indicator"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; -import type { Skill } from "@/services/api/skills"; +import type { Skill, SkillPreset } from "@/services/api/skills"; import { buildSkillMentionReferences } from "@/services/skill-runtime"; import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry"; @@ -513,6 +513,143 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false } ); } +/** + * 场景起步胶囊:把「我大概想做 X」一步翻译成一组技能。 + * 两组来源,都不限剧典技能: + * 1) 配方组——只读消费 GET /skills/presets(随二进制内置的手工策展配方) + * 2) 常用组——用户已装(其中可含已收藏)及自建的技能,按常用度排序(含官方种子库与自定义) + * 选择只作用于本会话;缺失的技能会持久安装到当前用户的技能库。 + * 挂上之后具体用哪张卡由 Agent 在任务里检索判断,胶囊只负责"把对的技能送到手边"。 + */ +export type AgentSceneBucket = { + key: string; + label: string; + presets: SkillPreset[]; + skills: Skill[]; +}; + +/** 场景分类:与 presets.json 的 scene 字段、技能的 tag 字段共用同一套 key。 */ +export const AGENT_SCENE_DEFS: Array<{ key: string; label: string }> = [ + { key: "frequent", label: "我的常用" }, + { key: "drama", label: "短剧故事" }, + { key: "ecommerce", label: "广告电商" }, + { key: "creative", label: "视觉创意" }, + { key: "social", label: "传播社媒" }, + { key: "others", label: "其他" }, +]; + +export function AgentSceneCapsules({ buckets, installedIds, theme, disabled = false, onPick, onPickSkill }: { + buckets: AgentSceneBucket[]; + installedIds: Set; + theme: (typeof canvasThemes)[keyof typeof canvasThemes]; + disabled?: boolean; + onPick: (preset: SkillPreset) => void; + onPickSkill: (skill: Skill) => void; +}) { + // 始终只占一排:默认显示场景分类,点某个场景后在同一排内就地切换内容。 + const [activeKey, setActiveKey] = useState(null); + const capsuleClass = "agent-scene-capsule shrink-0 rounded-lg px-3 py-1.5 text-left text-xs focus-visible:outline focus-visible:outline-2"; + const stop = { + onMouseDown: (event: { stopPropagation(): void }) => event.stopPropagation(), + onPointerDown: (event: { stopPropagation(): void }) => event.stopPropagation(), + }; + const visible = buckets.filter((bucket) => bucket.presets.length + bucket.skills.length > 0); + const active = activeKey ? visible.find((bucket) => bucket.key === activeKey) || null : null; + if (!visible.length) return null; + return ( +
+
+ + {active ? active.label : "技能组合推荐"} +
+
+ {active ? ( + <> + + {active.presets.map((preset) => { + const missing = preset.skillIds.filter((id) => !installedIds.has(id)).length; + return ( + + ); + })} + {active.skills.map((skill) => { + const owned = skill.isOwner ? "自建" : skill.isLike ? "已收藏" : installedIds.has(skill.skillId) ? "已装" : "待装"; + return ( + + ); + })} + + ) : ( + visible.map((bucket) => { + const count = bucket.presets.length + bucket.skills.length; + return ( + + ); + }) + )} +
+
选择只对当前会话生效;未装的技能会添加到我的技能库。具体用哪张卡由 Agent 按任务检索。
+
+ ); +} + export function AgentChatComposer({ prompt, attachments = [], diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index cc1aee498..a4737fdf7 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -4,6 +4,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ArrowLeft, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, MoveDiagonal2, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react"; import { saveAs } from "file-saver"; import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export"; +import { markdownPlainText } from "@/lib/markdown-plain-text"; import { agentToolRetry, mergeAgentToolRetry } from "@/lib/canvas/agent-tool-retry"; import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan"; import { nanoid } from "nanoid"; @@ -11,7 +12,6 @@ import { nanoid } from "nanoid"; import { ModelPicker } from "@/components/model-picker"; import { FluidOrb } from "@/components/ui/fluid-orb"; import { cn } from "@/lib/utils"; -import { markdownPlainText } from "@/lib/markdown-plain-text"; import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; @@ -21,15 +21,17 @@ import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentat import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval"; import type { AgentMediaSettings } from "@/services/api/agent"; import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings"; -import { addSkill, listAddedSkills, listSkills, type Skill, type SkillCategory } from "@/services/api/skills"; +import { addSkill, listAddedSkills, listSkills, listSkillPresets, type Skill, type SkillCategory, type SkillPreset } from "@/services/api/skills"; import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations"; import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useAppearanceStore } from "@/stores/use-appearance-store"; +import { useUserStore } from "@/stores/use-user-store"; +import { getActiveUserScope } from "@/lib/user-scope"; import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { createAgentCanvasSync } from "@/services/agent-canvas-sync"; import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime"; -import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, AgentWorkingMessage, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui"; +import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, AgentWorkingMessage, AGENT_SCENE_DEFS, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui"; import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal"; import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings"; import { useAgentPanelLayout } from "./use-agent-panel-layout"; @@ -45,6 +47,7 @@ type ApprovalState = { approvalId: string; detail: Record; reas type AgentPanelView = "chat" | "history" | "settings"; export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) { + const userId = useUserStore((state) => state.user?.id); const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; const theme = canvasThemes[useActiveTheme()]; const config = useEffectiveConfig(); @@ -92,6 +95,9 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re const planItems = useMemo(() => latestAgentPlanItems(messages), [messages]); const planVisible = agentPlanVisible(planItems); const pendingQuestion = useMemo(() => pendingAgentQuestion(messages), [messages]); + const [scenePresets, setScenePresets] = useState([]); + const [presetApplyingId, setPresetApplyingId] = useState(""); + const presetApplyingRef = useRef(null); const panelLayout = useAgentPanelLayout(); const lastSeqRef = useRef(0); const canvasSyncRef = useRef | null>(null); @@ -122,6 +128,124 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re useEffect(() => { if (!reasoningSupported && reasoningMode !== "off") setReasoningMode("off"); }, [reasoningSupported, reasoningMode]); const installedSkills = useMemo(() => skills.filter((skill) => skill.isAdded), [skills]); const enabledSkills = useMemo(() => installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)), [installedSkills, selectedSkillIds]); + const installedSkillIds = useMemo(() => new Set(installedSkills.map((skill) => skill.skillId)), [installedSkills]); + + const [createdSkills, setCreatedSkills] = useState([]); + + // 用户切换后重新加载,旧请求不得把其他账号的数据写回当前面板。 + useEffect(() => { + let active = true; + presetApplyingRef.current = null; + setPresetApplyingId(""); + setScenePresets([]); + listSkillPresets() + .then((result) => { if (active) setScenePresets(result.presets || []); }) + .catch(() => { if (active) setScenePresets([]); }); + return () => { active = false; }; + }, [userId]); + + // 用户自建技能也要能出现在推荐里:官方种子库与剧典走「已装」,自建走 scope=created。 + useEffect(() => { + let active = true; + setCreatedSkills([]); + listSkills({ scope: "created", pageSize: 50 }) + .then((result) => { if (active) setCreatedSkills(result.skills || []); }) + .catch((cause) => { if (active) setMessages((current) => appendAgentError(current, "created-skills-error", cause, "自建技能读取失败")); }); + return () => { active = false; }; + }, [userId]); + + // 场景分桶:把「常用 / 推荐配方 / 场景技能」收进同一个维度,一级只显示分类。 + // 常用度:自建 > 已收藏 > 已装,同级按市场热度降序。 + const sceneBuckets = useMemo(() => { + const merged = new Map(); + for (const skill of [...installedSkills, ...createdSkills]) { + if (!merged.has(skill.skillId)) merged.set(skill.skillId, skill); + } + const rank = (skill: Skill) => (skill.isOwner ? 0 : skill.isLike ? 1 : 2); + const frequent = [...merged.values()].sort((a, b) => rank(a) - rank(b) || (b.addedCount || 0) - (a.addedCount || 0)); + const pool = frequent.slice(0, 24); + const sceneOf = (skill: Skill) => skill.tag || "others"; + return AGENT_SCENE_DEFS.map((definition) => ({ + key: definition.key, + label: definition.label, + presets: definition.key === "frequent" ? [] : scenePresets.filter((preset) => preset.scene === definition.key), + skills: definition.key === "frequent" ? frequent.slice(0, 8) : pool.filter((skill) => sceneOf(skill) === definition.key), + })); + }, [installedSkills, createdSkills, scenePresets]); + + const applyScenePreset = useCallback(async (preset: SkillPreset) => { + if (running || busy || presetApplyingRef.current || !historyHydrated || !pendingHydrated) return; + const scope = conversationScope; + const account = getActiveUserScope(); + const token = crypto.randomUUID(); + const isCurrent = () => presetApplyingRef.current === token && currentScope.current === scope && getActiveUserScope() === account; + const missing = preset.skillIds.filter((id) => !installedSkillIds.has(id)); + presetApplyingRef.current = token; + setPresetApplyingId(preset.presetId); + try { + // 安装是持久写操作;任何失败都不能谎称整个配方已挂载。 + for (const id of missing) { + if (!isCurrent()) return; + await addSkill(id); + if (!isCurrent()) return; + } + const refreshed = await listAddedSkills(); + if (!isCurrent()) return; + if (preset.skillIds.some((id) => !refreshed.skills.some((skill) => skill.skillId === id && skill.isAdded))) { + throw new Error("技能库未确认全部预设技能已安装,请刷新后重试"); + } + setSkills(refreshed.skills); + setSelectedSkillIds(preset.skillIds); + setMessages((current) => appendUniqueMessage(current, { + id: `preset-${preset.presetId}-${Date.now()}`, + role: "system", + text: `已按「${preset.name}」挂上 ${preset.skillIds.length} 个技能${missing.length ? `(新装 ${missing.length} 个)` : ""}。${preset.rationale}`, + })); + } catch (cause) { + if (isCurrent()) setMessages((current) => appendAgentError(current, `preset-${preset.presetId}`, cause, `「${preset.name}」挂载失败(已安装的技能仍在技能库中)`)); + } finally { + if (presetApplyingRef.current === token) { + presetApplyingRef.current = null; + setPresetApplyingId(""); + } + } + }, [busy, conversationScope, historyHydrated, installedSkillIds, pendingHydrated, running]); + + // 单个技能(含用户自建)挂载到本会话;未装的先补装,已挂的不重复追加。 + const applySingleSkill = useCallback(async (skill: Skill) => { + if (running || busy || presetApplyingRef.current || !historyHydrated || !pendingHydrated) return; + const scope = conversationScope; + const account = getActiveUserScope(); + const token = crypto.randomUUID(); + const isCurrent = () => presetApplyingRef.current === token && currentScope.current === scope && getActiveUserScope() === account; + presetApplyingRef.current = token; + setPresetApplyingId(skill.skillId); + try { + if (!installedSkillIds.has(skill.skillId)) { + await addSkill(skill.skillId); + if (!isCurrent()) return; + } + const refreshed = await listAddedSkills(); + if (!isCurrent()) return; + if (!refreshed.skills.some((item) => item.skillId === skill.skillId && item.isAdded)) { + throw new Error("技能库未确认该技能已安装,请刷新后重试"); + } + setSkills(refreshed.skills); + setSelectedSkillIds((current) => (current.includes(skill.skillId) ? current : [...current, skill.skillId])); + setMessages((current) => appendUniqueMessage(current, { + id: `skill-${skill.skillId}-${Date.now()}`, + role: "system", + text: `已把「${skill.skillName}」挂到本会话。用哪张卡交给 Agent 按任务检索。`, + })); + } catch (cause) { + if (isCurrent()) setMessages((current) => appendAgentError(current, `skill-${skill.skillId}`, cause, `「${skill.skillName}」挂载失败`)); + } finally { + if (presetApplyingRef.current === token) { + presetApplyingRef.current = null; + setPresetApplyingId(""); + } + } + }, [busy, conversationScope, historyHydrated, installedSkillIds, pendingHydrated, running]); const status = run?.status || "idle"; const statusLabel = status === "waiting_approval" ? "等待审批" : status === "running" || status === "queued" ? "运行中" : status === "completed" ? "已完成" : status === "failed" ? "异常" : status === "cancelled" ? "已停止" : status === "rejected" ? "已拒绝" : "待命"; const statusColor = status === "failed" ? "#e66b6b" : status === "rejected" || status === "cancelled" ? theme.node.muted : status === "waiting_approval" ? "#d6a24a" : status === "running" || status === "queued" ? "#69c29b" : theme.node.muted; @@ -176,6 +300,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re useEffect(() => { let active = true; + setSkills([]); const refresh = () => { void listAddedSkills() .then((result) => { if (!active) return; @@ -191,7 +316,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re window.removeEventListener("canvas-skills-changed", refresh); window.removeEventListener("focus", refresh); }; - }, [open]); + }, [open, userId]); useEffect(() => { if (view !== "settings" && !skillsOpen) return; @@ -251,9 +376,12 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re setPendingHydrated(false); pendingSubmission.current = null; setBusy(false); + presetApplyingRef.current = null; + setPresetApplyingId(""); setConversations([]); setRun(null); setMessages([]); + setSelectedSkillIds([]); setApproval(null); setApprovalSubmitting(false); approvalRequestRef.current = null; @@ -290,7 +418,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re return () => { active = false; }; - }, [canvasId]); + }, [canvasId, userId]); useEffect(() => { if (!historyHydrated || (!messages.length && !run)) return; @@ -549,6 +677,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re const newConversation = () => { const id = nanoid(); currentScope.current = `${canvasId}:${id}`; + presetApplyingRef.current = null; + setPresetApplyingId(""); setPendingHydrated(true); setBusy(false); pendingSubmission.current = null; @@ -557,6 +687,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re setActiveConversationId(id); setRun(null); setMessages([]); + setSelectedSkillIds([]); setPrompt(""); setApproval(null); lastSeqRef.current = 0; @@ -565,6 +696,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re const openConversation = (conversation: CloudAgentConversation) => { currentScope.current = `${canvasId}:${conversation.id}`; + presetApplyingRef.current = null; + setPresetApplyingId(""); setPendingHydrated(false); setBusy(false); pendingSubmission.current = null; @@ -732,6 +865,16 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re onReject={() => void submitApproval("reject")} /> {planVisible ? setPlanMinimized((value) => !value)} /> : null} + {historyHydrated && !messages.some((message) => message.role === "user" || message.role === "assistant") && !run ? ( + void applyScenePreset(preset)} + onPickSkill={(skill) => void applySingleSkill(skill)} + /> + ) : null} {pendingQuestion ? ( } | null = null; let addedSkillsCache: { scope: string; value: { skills: Skill[] }; expiresAt: number } | null = null; +let addedSkillsCacheVersion = 0; export type SkillSort = "popular" | "new" | "updated"; export type SkillScope = "public" | "mine" | "created" | "favorites"; @@ -58,6 +59,22 @@ export type Skill = { export type SkillCategory = { value: string; label: string }; +/** + * 场景预设:平台只读目录(GET /skills/presets,随二进制内置)。 + * 内容是「一个起步场景 → 一组已上架技能 ID」,不含任何技能正文,也不占用用户配额。 + * 预设目录只读;选中技能作用于当前会话,缺失技能会持久安装到用户技能库。 + */ +export type SkillPreset = { + presetId: string; + name: string; + scene: string; + skillIds: string[]; + rationale: string; + source: string; + evidence: string; + upgrade: string; +}; + export type SkillList = { skills: Skill[]; totalCount: number; @@ -142,14 +159,20 @@ export function getSkill(id: string) { return http.get<{ skill: Skill }>(`/skills/${encodeURIComponent(id)}`); } +/** 场景预设目录:公开只读,与 /skills 同级的市场元数据,无需用户上下文。 */ +export function listSkillPresets() { + return http.get<{ presets: SkillPreset[] }>("/skills/presets"); +} + export function listAddedSkills() { const scope = getActiveUserScope(); + const version = addedSkillsCacheVersion; const now = Date.now(); if (addedSkillsCache?.scope === scope && addedSkillsCache.expiresAt > now) return Promise.resolve(addedSkillsCache.value); if (addedSkillsRequest?.scope === scope) return addedSkillsRequest.promise; const promise = readAddedSkillsWithRetry() .then((value) => { - addedSkillsCache = { scope, value, expiresAt: Date.now() + 15_000 }; + if (version === addedSkillsCacheVersion) addedSkillsCache = { scope, value, expiresAt: Date.now() + 15_000 }; return value; }) .finally(() => { @@ -172,7 +195,9 @@ async function readAddedSkillsWithRetry() { } function invalidateAddedSkillsCache() { + addedSkillsCacheVersion += 1; addedSkillsCache = null; + addedSkillsRequest = null; if (typeof window !== "undefined") window.dispatchEvent(new Event("canvas-skills-changed")); } diff --git a/web/test/skills-api.test.ts b/web/test/skills-api.test.ts index 23107c2c7..26178ef87 100644 --- a/web/test/skills-api.test.ts +++ b/web/test/skills-api.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { apiClient } from "@/services/api/request"; -import { listAddedSkills } from "@/services/api/skills"; +import { addSkill, listAddedSkills } from "@/services/api/skills"; test("added Skills retries a transient backend proxy failure", async () => { const original = apiClient.request; @@ -25,3 +25,31 @@ test("added Skills retries a transient backend proxy failure", async () => { apiClient.request = original; } }); + +test("a pending skills response cannot replace the cache after installation", async () => { + const original = apiClient.request; + let resolveStale: ((value: unknown) => void) | undefined; + const staleResponse = new Promise((resolve) => { resolveStale = resolve; }); + let reads = 0; + const envelope = (skills: unknown[]) => ({ data: { code: 0, data: { skills }, msg: "ok" }, status: 200, headers: {} }); + apiClient.request = (async (config) => { + if (config.method === "post") return envelope([]); + reads += 1; + return reads === 1 ? staleResponse : envelope([{ skillId: "preset", isAdded: true }]); + }) as typeof apiClient.request; + + try { + await addSkill("preset"); // Invalidate a cache left by another reader. + const pending = listAddedSkills(); + await addSkill("preset"); + const fresh = await listAddedSkills(); + resolveStale?.(envelope([])); + await pending; + expect(fresh.skills).toEqual([{ skillId: "preset", isAdded: true }]); + expect((await listAddedSkills()).skills).toEqual(fresh.skills); + expect(reads).toBe(2); + } finally { + resolveStale?.(envelope([])); + apiClient.request = original; + } +});