feat(agent): 场景胶囊 - 为会话挂载预设技能组合 (#608)
This commit is contained in:
@@ -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<string>;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
disabled?: boolean;
|
||||
onPick: (preset: SkillPreset) => void;
|
||||
onPickSkill: (skill: Skill) => void;
|
||||
}) {
|
||||
// 始终只占一排:默认显示场景分类,点某个场景后在同一排内就地切换内容。
|
||||
const [activeKey, setActiveKey] = useState<string | null>(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 (
|
||||
<div className="agent-scene-capsules mx-3 mb-2 overflow-hidden rounded-xl" style={{ color: theme.node.text }}>
|
||||
<div className="flex items-start gap-2 px-3 pt-2.5">
|
||||
<Sparkles className="mt-[1px] size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
|
||||
<span className="min-w-0 flex-1 text-xs font-semibold leading-5">{active ? active.label : "技能组合推荐"}</span>
|
||||
</div>
|
||||
<div className="agent-scene-capsules-scroll thin-scrollbar flex gap-2 overflow-x-auto px-3 pb-1 pt-2">
|
||||
{active ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title="返回全部场景"
|
||||
className={capsuleClass}
|
||||
{...stop}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setActiveKey(null);
|
||||
}}
|
||||
>
|
||||
<span className="block whitespace-nowrap font-medium">← 全部场景</span>
|
||||
<span className="mt-0.5 block whitespace-nowrap text-[10px] leading-4 opacity-60">返回</span>
|
||||
</button>
|
||||
{active.presets.map((preset) => {
|
||||
const missing = preset.skillIds.filter((id) => !installedIds.has(id)).length;
|
||||
return (
|
||||
<button
|
||||
key={preset.presetId}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={preset.rationale}
|
||||
className={capsuleClass}
|
||||
{...stop}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onPick(preset);
|
||||
}}
|
||||
>
|
||||
<span className="block whitespace-nowrap font-medium">{preset.name}</span>
|
||||
<span className="mt-0.5 block whitespace-nowrap text-[10px] leading-4 opacity-60">
|
||||
{missing > 0 ? `${preset.skillIds.length} 个技能 · ${missing} 个待装` : `${preset.skillIds.length} 个技能`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{active.skills.map((skill) => {
|
||||
const owned = skill.isOwner ? "自建" : skill.isLike ? "已收藏" : installedIds.has(skill.skillId) ? "已装" : "待装";
|
||||
return (
|
||||
<button
|
||||
key={skill.skillId}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={skill.description}
|
||||
className={capsuleClass}
|
||||
{...stop}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onPickSkill(skill);
|
||||
}}
|
||||
>
|
||||
<span className="block max-w-[10rem] truncate font-medium">{skill.skillName}</span>
|
||||
<span className="mt-0.5 block whitespace-nowrap text-[10px] leading-4 opacity-60">{owned}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
visible.map((bucket) => {
|
||||
const count = bucket.presets.length + bucket.skills.length;
|
||||
return (
|
||||
<button
|
||||
key={bucket.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={`查看「${bucket.label}」下的技能组合`}
|
||||
className={capsuleClass}
|
||||
{...stop}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setActiveKey(bucket.key);
|
||||
}}
|
||||
>
|
||||
<span className="block whitespace-nowrap font-medium">{bucket.label}</span>
|
||||
<span className="mt-0.5 block whitespace-nowrap text-[10px] leading-4 opacity-60">{count} 项</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 pb-2 text-[10px] opacity-50">选择只对当前会话生效;未装的技能会添加到我的技能库。具体用哪张卡由 Agent 按任务检索。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChatComposer({
|
||||
prompt,
|
||||
attachments = [],
|
||||
|
||||
@@ -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<string, unknown>; 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<SkillPreset[]>([]);
|
||||
const [presetApplyingId, setPresetApplyingId] = useState("");
|
||||
const presetApplyingRef = useRef<string | null>(null);
|
||||
const panelLayout = useAgentPanelLayout();
|
||||
const lastSeqRef = useRef(0);
|
||||
const canvasSyncRef = useRef<ReturnType<typeof createAgentCanvasSync> | 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<Skill[]>([]);
|
||||
|
||||
// 用户切换后重新加载,旧请求不得把其他账号的数据写回当前面板。
|
||||
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<AgentSceneBucket[]>(() => {
|
||||
const merged = new Map<string, Skill>();
|
||||
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 ? <AgentPlanBar items={planItems} theme={theme} minimized={planMinimized} onToggle={() => setPlanMinimized((value) => !value)} /> : null}
|
||||
{historyHydrated && !messages.some((message) => message.role === "user" || message.role === "assistant") && !run ? (
|
||||
<AgentSceneCapsules
|
||||
buckets={sceneBuckets}
|
||||
installedIds={installedSkillIds}
|
||||
theme={theme}
|
||||
disabled={busy || running || !pendingHydrated || Boolean(presetApplyingId)}
|
||||
onPick={(preset) => void applyScenePreset(preset)}
|
||||
onPickSkill={(skill) => void applySingleSkill(skill)}
|
||||
/>
|
||||
) : null}
|
||||
{pendingQuestion ? (
|
||||
<AgentQuestionBar
|
||||
question={pendingQuestion}
|
||||
|
||||
@@ -216,6 +216,56 @@
|
||||
background: var(--agent-surface-raised, var(--background));
|
||||
}
|
||||
|
||||
/* 场景起步胶囊:选择作用于会话,未安装的技能会加入用户技能库。 */
|
||||
.agent-scene-capsules {
|
||||
border: 0;
|
||||
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent));
|
||||
}
|
||||
|
||||
.agent-scene-capsule {
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
background: var(--agent-surface-raised, var(--background));
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.agent-scene-capsule:hover:not(:disabled),
|
||||
.agent-scene-capsule:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--agent-accent, var(--foreground)) 45%, transparent);
|
||||
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 6%, transparent));
|
||||
}
|
||||
|
||||
.agent-scene-capsule:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.agent-scene-capsule:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.agent-scene-capsules-scroll {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.agent-scene-capsules-scroll::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.agent-scene-capsules-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--foreground) 14%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-scene-capsule {
|
||||
transition: none;
|
||||
}
|
||||
.agent-scene-capsule:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 400px) {
|
||||
.agent-panel-header {
|
||||
gap: var(--space-2);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
|
||||
|
||||
let addedSkillsRequest: { scope: string; promise: Promise<{ skills: Skill[] }> } | 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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user