feat(canvas): 云端 Agent - 完成策略分层、节点能力注册与可解释审批改造

This commit is contained in:
ddCat
2026-09-14 17:58:21 +08:00
parent 4b0e9d8965
commit 01486872c4
65 changed files with 5347 additions and 918 deletions

View File

@@ -0,0 +1,289 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Input } from "antd";
import { Check, LibraryBig, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react";
import { AppModal } from "@/components/ui/product/app-modal";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { Skill, SkillCategory } from "@/services/api/skills";
type SkillLibraryTab = "enabled" | "installed" | "market";
type CanvasAgentSkillLibraryModalProps = {
open: boolean;
theme: CanvasTheme;
installedSkills: Skill[];
marketSkills: Skill[];
selectedSkillIds: string[];
categories: SkillCategory[];
category: string;
search: string;
loading: boolean;
hasMore: boolean;
onClose: () => void;
onCategoryChange: (value: string) => void;
onSearch: (value: string) => void;
onToggle: (skillId: string) => void;
onInstall: (skill: Skill) => Promise<void>;
onLoadMore: () => Promise<void>;
};
export function CanvasAgentSkillLibraryModal({
open,
theme,
installedSkills,
marketSkills,
selectedSkillIds,
categories,
category,
search,
loading,
hasMore,
onClose,
onCategoryChange,
onSearch,
onToggle,
onInstall,
onLoadMore,
}: CanvasAgentSkillLibraryModalProps) {
const [tab, setTab] = useState<SkillLibraryTab>("enabled");
const listRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(false);
const selectedCount = selectedSkillIds.length;
useEffect(() => {
if (open && !wasOpenRef.current) setTab(selectedCount > 0 ? "enabled" : "installed");
wasOpenRef.current = open;
}, [open, selectedCount]);
const visibleSkills = useMemo(() => {
const keyword = search.trim().toLocaleLowerCase("zh-CN");
const source = tab === "enabled"
? installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId))
: tab === "installed"
? installedSkills
: marketSkills;
return source.filter((skill) => {
if (category !== "all" && skill.tag !== category) return false;
if (!keyword) return true;
return `${skill.skillName} ${skill.description || ""} ${skill.effectiveUser?.name || ""}`
.toLocaleLowerCase("zh-CN")
.includes(keyword);
});
}, [category, installedSkills, marketSkills, search, selectedSkillIds, tab]);
useEffect(() => {
const target = loadMoreRef.current;
if (tab !== "market" || !target || !hasMore || loading || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) void onLoadMore();
}, { root: listRef.current, rootMargin: "180px 0px" });
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, loading, onLoadMore, tab, visibleSkills.length]);
const categoryItems = useMemo(() => {
const unique = new Map<string, SkillCategory>();
categories.forEach((item) => {
if (item.value && item.label) unique.set(item.value, item);
});
return [{ value: "all", label: "全部" }, ...unique.values()];
}, [categories]);
const emptyText = tab === "enabled"
? "本轮还没有启用 Skill,可在“已加入”中选择"
: tab === "installed"
? "还没有匹配的已加入 Skill,可前往“发现 Skills”添加"
: "没有匹配的公开 Skill,换个关键词或分类试试";
return (
<AppModal
rootClassName="canvas-agent-skill-library-modal"
open={open}
title={null}
footer={null}
centered
width="min(1180px, calc(100vw - 24px))"
onCancel={onClose}
flush
>
<section
className="canvas-agent-skill-library-shell"
style={{ color: theme.node.text, background: theme.node.panel }}
data-canvas-no-zoom
data-canvas-wheel-scroll
aria-label="Agent Skills 技能库"
>
<header className="canvas-agent-skill-library-header">
<div className="min-w-0">
<div className="flex items-center gap-2 text-lg font-semibold">
<LibraryBig className="size-5" aria-hidden="true" />
Skills 技能库
</div>
<p className="mt-1 text-xs" style={{ color: theme.node.muted }}>按分类浏览和搜索技能,本轮最多启用 8 个,发送消息时固定版本。</p>
</div>
<div className="canvas-agent-skill-library-count" style={{ color: theme.node.muted }}>
已启用 <strong style={{ color: selectedCount >= 8 ? "var(--destructive)" : theme.accent.primary }}>{selectedCount}</strong>/8
</div>
</header>
<div className="canvas-agent-skill-library-toolbar">
<div className="canvas-agent-skill-library-tabs" role="tablist" aria-label="Skills 视图">
<SkillTab active={tab === "enabled"} label="已启用" count={selectedCount} onClick={() => setTab("enabled")} />
<SkillTab active={tab === "installed"} label="已加入" count={installedSkills.length} onClick={() => setTab("installed")} />
<SkillTab active={tab === "market"} label="发现 Skills" onClick={() => setTab("market")} />
</div>
<Input
allowClear
prefix={<Search className="size-4" style={{ color: theme.node.muted }} aria-hidden="true" />}
value={search}
onChange={(event) => onSearch(event.target.value)}
placeholder="搜索 Skill 名称、说明或作者"
aria-label="搜索 Skills"
className="canvas-agent-skill-library-search"
/>
</div>
<nav className="canvas-agent-skill-library-categories" aria-label="Skill 分类">
{categoryItems.map((item) => (
<button
key={item.value}
type="button"
className={`canvas-agent-skill-category ${category === item.value ? "is-active" : ""}`}
aria-pressed={category === item.value}
onClick={() => onCategoryChange(item.value)}
>
{item.label}
</button>
))}
</nav>
<div ref={listRef} className="canvas-agent-skill-library-list thin-scrollbar" role="tabpanel">
{visibleSkills.map((skill) => (
<SkillLibraryCard
key={skill.skillId}
skill={skill}
theme={theme}
categories={categories}
selected={selectedSkillIds.includes(skill.skillId)}
canSelect={selectedCount < 8 || selectedSkillIds.includes(skill.skillId)}
onToggle={() => onToggle(skill.skillId)}
onInstall={() => onInstall(skill)}
/>
))}
{loading && visibleSkills.length === 0 ? (
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
<LoaderCircle className="size-5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
正在读取技能库…
</div>
) : null}
{!loading && visibleSkills.length === 0 ? (
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
<Sparkles className="size-6" aria-hidden="true" />
<span>{emptyText}</span>
{tab !== "market" ? <Button size="small" onClick={() => setTab("market")}>浏览公开 Skills</Button> : null}
</div>
) : null}
{tab === "market" && hasMore ? <div ref={loadMoreRef} className="col-span-full h-px" aria-hidden="true" /> : null}
</div>
<footer className="canvas-agent-skill-library-footer" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}>
<span>{tab === "enabled" ? `${selectedCount} 个技能将在本轮生效` : tab === "installed" ? `${visibleSkills.length} 个已加入技能` : `已加载 ${marketSkills.length} 个公开技能`}</span>
{tab === "market" && hasMore ? (
<Button size="small" disabled={loading} loading={loading} onClick={() => void onLoadMore()}>{loading ? "加载中" : "加载更多"}</Button>
) : <span>{tab === "market" ? "已加载全部" : "最多启用 8 个"}</span>}
</footer>
</section>
</AppModal>
);
}
function SkillTab({ active, label, count, onClick }: { active: boolean; label: string; count?: number; onClick: () => void }) {
return (
<button type="button" role="tab" aria-selected={active} className={`canvas-agent-skill-library-tab ${active ? "is-active" : ""}`} onClick={onClick}>
{label}
{typeof count === "number" ? <span>{count}</span> : null}
</button>
);
}
function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onToggle, onInstall }: {
skill: Skill;
theme: CanvasTheme;
categories: SkillCategory[];
selected: boolean;
canSelect: boolean;
onToggle: () => void;
onInstall: () => Promise<void>;
}) {
const [installing, setInstalling] = useState(false);
const [coverFailed, setCoverFailed] = useState(false);
const coverUrl = skill.showcaseMedia?.find((item) => item.showcaseUrl)?.showcaseUrl;
const categoryLabel = categories.find((item) => item.value === skill.tag)?.label || "其他";
const author = skill.effectiveUser?.name || "影策创作者";
const addedCount = formatSkillCount(skill.addedCount || 0);
useEffect(() => setCoverFailed(false), [coverUrl]);
const install = async () => {
if (installing) return;
setInstalling(true);
try {
await onInstall();
} finally {
setInstalling(false);
}
};
return (
<article className={`canvas-agent-skill-card ${selected ? "is-selected" : ""}`} style={{ borderColor: selected ? theme.accent.primary : theme.node.stroke }}>
<div className="canvas-agent-skill-card-cover" style={{ background: theme.node.fill }}>
{coverUrl && !coverFailed ? (
<img src={coverUrl} alt="" loading="lazy" onError={() => setCoverFailed(true)} />
) : (
<div className="canvas-agent-skill-card-placeholder" aria-hidden="true">
<Sparkles className="size-7" />
</div>
)}
<span className="canvas-agent-skill-card-category">{categoryLabel}</span>
</div>
<div className="canvas-agent-skill-card-body">
<div className="canvas-agent-skill-card-title-row">
<h3 title={skill.skillName}>{skill.skillName}</h3>
{skill.version ? <span className="canvas-agent-skill-card-version">v{skill.version}</span> : null}
</div>
<p title={skill.description || "暂无技能说明"}>{skill.description || "暂无技能说明"}</p>
<div className="canvas-agent-skill-card-meta" style={{ color: theme.node.muted }}>
<span className="min-w-0 truncate">{author}</span>
<span aria-hidden="true">·</span>
<span className="inline-flex shrink-0 items-center gap-1"><Users className="size-3.5" aria-hidden="true" />{addedCount}</span>
</div>
</div>
<div className="canvas-agent-skill-card-action">
{skill.isAdded ? (
<Button
size="small"
type="default"
className={selected ? "is-selected" : ""}
icon={selected ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
disabled={!canSelect}
aria-pressed={selected}
title={!canSelect ? "本轮最多启用 8 个 Skills" : undefined}
onClick={onToggle}
>
{selected ? "已启用" : "启用"}
</Button>
) : (
<Button size="small" icon={<Plus className="size-3.5" />} loading={installing} disabled={installing} onClick={() => void install()}>
加入
</Button>
)}
</div>
</article>
);
}
function formatSkillCount(value: number) {
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value);
}

View File

@@ -570,8 +570,8 @@ export function AgentChatComposer({
</div>
) : null}
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1">
<div className="agent-composer-toolbar mt-2">
<div className="agent-composer-controls flex min-w-0 items-center gap-1">
{onAddFiles ? (
<>
<input
@@ -600,7 +600,7 @@ export function AgentChatComposer({
) : null}
{left}
</div>
<div className="flex min-w-0 items-center gap-2">
<div className="agent-composer-submit flex items-center gap-2">
<span className="agent-composer-send-hint">Enter 换行 · ⌘/Ctrl+Enter 发送</span>
<motion.button
type="button"

View File

@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { Button, Dropdown, Input, Popover } from "antd";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { Button, Dropdown, Input } from "antd";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, Plus, Search, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, 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";
@@ -11,10 +11,10 @@ import { ModelPicker } from "@/components/model-picker";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { agentErrorPresentation } from "@/lib/canvas/agent-error-presentation";
import { cancelAgentRun, getAgentCapabilities, getAgentRun, createAgentRun, decideAgentApproval, sendAgentMessage, subscribeAgentEvents, type AgentEvent, type AgentPermissionMode, type AgentPersonality, type AgentRun } from "@/services/api/agent";
import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation";
import { cancelAgentRun, getAgentCapabilities, getAgentProfile, getAgentRun, createAgentRun, decideAgentApproval, sendAgentMessage, subscribeAgentEvents, updateAgentProfile, type AgentEvent, type AgentPermissionMode, type AgentProfileScope, type AgentProfileView, type AgentReasoningMode, type AgentRun } from "@/services/api/agent";
import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation";
import { addSkill, listAddedSkills, listSkills, type Skill } from "@/services/api/skills";
import { addSkill, listAddedSkills, listSkills, type Skill, type SkillCategory } from "@/services/api/skills";
import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations";
import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -22,15 +22,16 @@ import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
import { AgentChatComposer, AgentChatMessage, AgentWorkingMessage, type CloudAgentChatMessage } 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";
import "./canvas-cloud-agent.css";
type CloudAgentPanelProps = { canvasId: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void };
type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void };
type ApprovalState = { approvalId: string; detail: Record<string, unknown>; reason: string };
type AgentPanelView = "chat" | "history" | "settings";
export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) {
export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const config = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig);
@@ -41,8 +42,12 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
const [connectionEpoch, setConnectionEpoch] = useState(0);
const [messages, setMessages] = useState<CloudAgentChatMessage[]>([]);
const [prompt, setPrompt] = useState("");
const [thinking, setThinking] = useState(false);
const [personality, setPersonality] = useState<AgentPersonality>("务实");
const [reasoningMode, setReasoningMode] = useState<AgentReasoningMode>("off");
const [profileView, setProfileView] = useState<AgentProfileView | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [profileError, setProfileError] = useState<string>();
const profileRequestRef = useRef(0);
const [skills, setSkills] = useState<Skill[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [marketSkills, setMarketSkills] = useState<Skill[]>([]);
@@ -51,6 +56,8 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
const [skillsLoading, setSkillsLoading] = useState(false);
const [skillHasMore, setSkillHasMore] = useState(false);
const [skillPage, setSkillPage] = useState(1);
const [skillCategories, setSkillCategories] = useState<SkillCategory[]>([]);
const [skillTag, setSkillTag] = useState("all");
const [skillsOpen, setSkillsOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [approvalSubmitting, setApprovalSubmitting] = useState(false);
@@ -88,8 +95,8 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
currentScope.current = conversationScope;
const running = Boolean(run?.cleanupPending) || run?.status === "running" || run?.status === "queued" || run?.status === "waiting_approval";
const selectedModel = config.textModel || config.model || "";
const thinkingSupported = Boolean(modelCapabilityConfigFor(config, selectedModel).text?.thinking);
useEffect(() => { if (!thinkingSupported && thinking) setThinking(false); }, [thinkingSupported, thinking]);
const reasoningSupported = Boolean(modelCapabilityConfigFor(config, selectedModel).text?.thinking);
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 status = run?.status || "idle";
@@ -100,6 +107,37 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
if (!open || view !== "chat") setSkillsOpen(false);
}, [open, view]);
const reloadProfile = useCallback(async () => {
const requestId = ++profileRequestRef.current;
setProfileLoading(true);
setProfileError(undefined);
setProfileView(null);
try {
const result = await getAgentProfile({ projectId: domainProjectId, canvasId });
if (profileRequestRef.current === requestId) setProfileView(result);
} catch (cause) {
if (profileRequestRef.current === requestId) setProfileError(cause instanceof Error ? cause.message : String(cause));
} finally {
if (profileRequestRef.current === requestId) setProfileLoading(false);
}
}, [canvasId, domainProjectId]);
useEffect(() => {
void reloadProfile();
}, [reloadProfile]);
const saveProfile = async (input: { scope: AgentProfileScope; projectId?: string; canvasId?: string; content: string; revision: number }) => {
setProfileSaving(true);
try {
const result = await updateAgentProfile(input);
setProfileView(result);
setProfileError(undefined);
return result;
} finally {
setProfileSaving(false);
}
};
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSkillSearch(skillSearch.trim()), 250);
return () => window.clearTimeout(timer);
@@ -128,12 +166,19 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
if (view !== "settings" && !skillsOpen) return;
let active = true;
setSkillsLoading(true);
void listSkills({ scope: "public", search: debouncedSkillSearch || undefined, pageSize: 20, sort: "popular" })
void listSkills({
scope: "public",
search: debouncedSkillSearch || undefined,
tag: skillsOpen && skillTag !== "all" ? skillTag : undefined,
pageSize: 20,
sort: "popular",
})
.then((result) => {
if (active) {
setMarketSkills(result.skills);
setSkillHasMore(result.hasMore);
setSkillPage(result.page);
if (result.categories.length > 0) setSkillCategories(result.categories);
}
})
.catch(() => {
@@ -145,14 +190,21 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
return () => {
active = false;
};
}, [view, skillsOpen, debouncedSkillSearch]);
}, [view, skillsOpen, debouncedSkillSearch, skillTag]);
const loadMoreSkills = async () => {
if (skillsLoading || skillPageRequestRef.current || !skillHasMore || skillSearch.trim() !== debouncedSkillSearch) return;
skillPageRequestRef.current = true;
setSkillsLoading(true);
try {
const result = await listSkills({ scope: "public", search: debouncedSkillSearch || undefined, page: skillPage + 1, pageSize: 20, sort: "popular" });
const result = await listSkills({
scope: "public",
search: debouncedSkillSearch || undefined,
tag: skillsOpen && skillTag !== "all" ? skillTag : undefined,
page: skillPage + 1,
pageSize: 20,
sort: "popular",
});
setMarketSkills((current) => [...current, ...result.skills.filter((skill) => !current.some((item) => item.skillId === skill.skillId))]);
setSkillPage(result.page);
setSkillHasMore(result.hasMore);
@@ -288,6 +340,8 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
// Editing model settings or prompt must not silently create a new charge.
if (pending?.request && pending.request.prompt !== value) throw new Error("上一条请求结果待确认,请先原样重试上一条消息,再发送新要求");
if (!pending?.request) {
if (profileLoading) throw new Error("正在确认长期偏好快照,请稍后再发送");
if (!profileView || profileError) throw new Error("长期偏好快照尚未确认,请重新读取后再发送");
const capabilities = await getAgentCapabilities();
if (currentScope.current !== scope) return;
if (!capabilities.permissionModes.includes(permissionMode)) throw new Error("当前后端不支持所选 Agent 权限,请更新后端");
@@ -298,7 +352,7 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
const requestConfig = resolveModelRequestConfig(agentConfig, selectedModel);
const logicalModelId = logicalModelIDForConfig(agentConfig);
const input = {
canvasId, prompt: value, thinking, personality,
canvasId, prompt: value, reasoningMode: reasoningSupported ? reasoningMode : "off", profileRevision: profileView.revision,
model: modelOptionName(selectedModel) || undefined,
...(logicalModelId ? { logicalModelId } : requestConfig.channelId ? { channelId: requestConfig.channelId, channelModelKey: modelOptionName(selectedModel) || undefined } : {}),
skillIds: [...new Set([...selectedSkillIds, ...resolveSkillMentions(value, installedSkills).map((skill) => skill.skillId)])],
@@ -351,7 +405,7 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
}
}
}
setMessages((current) => appendAgentError(current, `submit-error-${activeConversationId}`, cause, accepted ? "运行已接收,但本地提交记录清理失败" : "请求未确认;重试将核对原请求,不重复创建"));
setMessages((current) => appendAgentError(current, `submit-error-${activeConversationId}`, cause, agentSubmissionErrorTitle(cause, accepted)));
} finally {
submissionRequestRef.current = false;
if (currentScope.current === scope) setBusy(false);
@@ -423,7 +477,7 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
const result = await addSkill(skill.skillId);
setSkills((current) => [...current.filter((item) => item.skillId !== skill.skillId), result.skill]);
setMarketSkills((current) => current.map((item) => (item.skillId === skill.skillId ? result.skill : item)));
setSelectedSkillIds((current) => (current.includes(skill.skillId) ? current : [...current, skill.skillId]));
setSelectedSkillIds((current) => (current.includes(skill.skillId) || current.length >= 8 ? current : [...current, skill.skillId]));
} catch (cause) {
setMessages((current) => appendAgentError(current, `skill-error-${Date.now()}`, cause, "添加 Skill 失败"));
}
@@ -536,8 +590,16 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
onBack={() => setView("chat")}
onModelChange={setModel}
onPermissionChange={setPermissionMode}
personality={personality}
onPersonalityChange={setPersonality}
reasoningMode={reasoningMode}
onReasoningModeChange={setReasoningMode}
profileView={profileView}
profileLoading={profileLoading}
profileSaving={profileSaving}
profileError={profileError}
projectId={domainProjectId}
canvasId={canvasId}
onReloadProfile={reloadProfile}
onSaveProfile={saveProfile}
onContextToggle={(value) => setContextScope((current) => (current.includes(value) ? current.filter((item) => item !== value) : [...current, value]))}
onSkillSearch={setSkillSearch}
onSkillToggle={(id) => setSelectedSkillIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id]))}
@@ -622,29 +684,17 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
includeAssetLibrary={false}
left={
<ComposerControls
thinking={thinkingSupported && thinking}
onThinkingChange={(value) => { if (thinkingSupported) setThinking(value); }}
thinkingSupported={thinkingSupported}
reasoningMode={reasoningSupported ? reasoningMode : "off"}
reasoningSupported={reasoningSupported}
onReasoningModeChange={(value) => { if (reasoningSupported) setReasoningMode(value); }}
config={config}
selectedModel={selectedModel}
permissionMode={permissionMode}
theme={theme}
onModelChange={setModel}
onPermissionChange={setPermissionMode}
personality={personality}
onPersonalityChange={setPersonality}
skillsOpen={skillsOpen}
onSkillsOpenChange={setSkillsOpen}
installedSkills={installedSkills}
marketSkills={marketSkills}
selectedSkillIds={selectedSkillIds}
skillSearch={skillSearch}
skillsLoading={skillsLoading}
skillHasMore={skillHasMore}
onSkillSearch={setSkillSearch}
onSkillToggle={(id) => setSelectedSkillIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id]))}
onSkillInstall={installSkill}
onLoadMoreSkills={loadMoreSkills}
selectedSkillCount={selectedSkillIds.length}
/>
}
@@ -655,6 +705,27 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o
</motion.aside>
) : null}
</AnimatePresence>
<CanvasAgentSkillLibraryModal
open={skillsOpen}
theme={theme}
installedSkills={installedSkills}
marketSkills={marketSkills}
selectedSkillIds={selectedSkillIds}
categories={skillCategories}
category={skillTag}
search={skillSearch}
loading={skillsLoading}
hasMore={skillHasMore}
onClose={() => setSkillsOpen(false)}
onCategoryChange={setSkillTag}
onSearch={setSkillSearch}
onToggle={(id) => setSelectedSkillIds((current) => {
if (current.includes(id)) return current.filter((item) => item !== id);
return current.length < 8 ? [...current, id] : current;
})}
onInstall={installSkill}
onLoadMore={loadMoreSkills}
/>
</>
);
}
@@ -810,7 +881,7 @@ function AgentConversation({
{messages.map((item) => (
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && item.streaming === true && item === messages.at(-1)} />
))}
{approval ? <ApprovalCard key={approval.approvalId} approval={approval} theme={theme} submitting={approvalSubmitting} onReasonChange={onApprovalReasonChange} onApprove={onApprove} onReject={onReject} /> : null}
{approval ? <ApprovalCard key={approval.approvalId} approval={approval} theme={theme} submitting={approvalSubmitting} onFocusNode={onFocusNode} onReasonChange={onApprovalReasonChange} onApprove={onApprove} onReject={onReject} /> : null}
{busy && !approval ? (
<AgentWorkingMessage theme={theme} label="正在处理当前画布" />
) : null}
@@ -832,11 +903,9 @@ function AgentEmptyState({ theme, nodeCount }: { theme: CanvasTheme; nodeCount:
}
function ComposerControls({
thinking,
thinkingSupported,
personality,
onPersonalityChange,
onThinkingChange,
reasoningMode,
reasoningSupported,
onReasoningModeChange,
config,
selectedModel,
permissionMode,
@@ -845,23 +914,11 @@ function ComposerControls({
onPermissionChange,
skillsOpen,
onSkillsOpenChange,
installedSkills,
marketSkills,
selectedSkillIds,
skillSearch,
skillsLoading,
skillHasMore,
onSkillSearch,
onSkillToggle,
onSkillInstall,
onLoadMoreSkills,
selectedSkillCount,
}: {
thinking: boolean;
thinkingSupported: boolean;
personality: AgentPersonality;
onPersonalityChange: (value: AgentPersonality) => void;
onThinkingChange: (value: boolean) => void;
reasoningMode: AgentReasoningMode;
reasoningSupported: boolean;
onReasoningModeChange: (value: AgentReasoningMode) => void;
config: ReturnType<typeof useEffectiveConfig>;
selectedModel: string;
permissionMode: AgentPermissionMode;
@@ -870,16 +927,6 @@ function ComposerControls({
onPermissionChange: (mode: AgentPermissionMode) => void;
skillsOpen: boolean;
onSkillsOpenChange: (open: boolean) => void;
installedSkills: Skill[];
marketSkills: Skill[];
selectedSkillIds: string[];
skillSearch: string;
skillsLoading: boolean;
skillHasMore: boolean;
onSkillSearch: (value: string) => void;
onSkillToggle: (skillId: string) => void;
onSkillInstall: (skill: Skill) => Promise<void>;
onLoadMoreSkills: () => Promise<void>;
selectedSkillCount: number;
}) {
const permissionVisual = agentPermissionVisual(permissionMode);
@@ -896,9 +943,11 @@ function ComposerControls({
showOptionPrices
placeholder="选择模型"
/>
{thinkingSupported ? <button type="button" aria-pressed={thinking} title="思考模式:由当前文本模型提供" onClick={() => onThinkingChange(!thinking)} className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25" style={{ color: thinking ? theme.accent.primary : theme.node.muted, background: thinking ? theme.node.fill : "transparent" }}>
<Sparkles className="size-3.5" />思考
</button> : null}
{reasoningSupported ? <Dropdown trigger={["click"]} placement="topLeft" menu={{ items: reasoningMenuItems(reasoningMode, onReasoningModeChange) }}>
<button type="button" aria-label="选择 Agent 推理模式" title="推理模式:只用于规划和工具选择" className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25" style={{ color: reasoningMode === "off" ? theme.node.muted : theme.accent.primary, background: reasoningMode === "off" ? "transparent" : theme.node.fill }}>
<Sparkles className="size-3.5" />{reasoningModeLabel(reasoningMode)}
</button>
</Dropdown> : null}
<Dropdown trigger={["click"]} placement="topLeft" menu={{ items: agentPermissionMenuItems(permissionMode, onPermissionChange) }}>
<button
type="button"
@@ -910,163 +959,37 @@ function ComposerControls({
<span className="max-w-20 truncate">{agentPermissionLabel(permissionMode)}</span>
</button>
</Dropdown>
<Popover
trigger="click"
placement="topLeft"
arrow={false}
open={skillsOpen}
onOpenChange={onSkillsOpenChange}
classNames={{ root: "canvas-agent-skill-popover" }}
content={
<AgentSkillPicker
theme={theme}
installedSkills={installedSkills}
marketSkills={marketSkills}
selectedSkillIds={selectedSkillIds}
search={skillSearch}
loading={skillsLoading}
hasMore={skillHasMore}
onSearch={onSkillSearch}
onToggle={onSkillToggle}
onInstall={onSkillInstall}
onLoadMore={onLoadMoreSkills}
/>
}
>
<button
type="button"
className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
style={{ color: selectedSkillCount ? theme.accent.primary : theme.node.muted }}
aria-label={`选择 Skills${selectedSkillCount ? `,已选 ${selectedSkillCount} 个` : ""}`}
aria-expanded={skillsOpen}
title="选择 Skills"
>
<Sparkles className="size-3.5" />
<span className="max-w-28 truncate">Skills技能包({selectedSkillCount})</span>
</button>
</Popover>
<button
type="button"
className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
style={{ color: selectedSkillCount ? theme.accent.primary : theme.node.muted, background: skillsOpen ? theme.node.fill : "transparent" }}
aria-label={`打开 Skills 技能库${selectedSkillCount ? `,已启用 ${selectedSkillCount} 个` : ""}`}
aria-expanded={skillsOpen}
aria-haspopup="dialog"
title="打开 Skills 技能库"
onClick={() => onSkillsOpenChange(true)}
>
<Sparkles className="size-3.5" />
<span className="max-w-28 truncate">Skills技能包({selectedSkillCount})</span>
</button>
</div>
);
}
function AgentSkillPicker({
theme,
installedSkills,
marketSkills,
selectedSkillIds,
search,
loading,
hasMore,
onSearch,
onToggle,
onInstall,
onLoadMore,
}: {
theme: CanvasTheme;
installedSkills: Skill[];
marketSkills: Skill[];
selectedSkillIds: string[];
search: string;
loading: boolean;
hasMore: boolean;
onSearch: (value: string) => void;
onToggle: (skillId: string) => void;
onInstall: (skill: Skill) => Promise<void>;
onLoadMore: () => Promise<void>;
}) {
const [tab, setTab] = useState<"installed" | "market">("installed");
const listRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const normalizedSearch = search.trim().toLocaleLowerCase();
const source = (tab === "installed" ? installedSkills : marketSkills).filter((skill) => `${skill.skillName} ${skill.description || ""}`.toLocaleLowerCase().includes(normalizedSearch));
const selectedCount = selectedSkillIds.length;
const allSelected = tab === "installed" && source.length > 0 && source.every((skill) => selectedSkillIds.includes(skill.skillId));
const toggleAll = () => {
let added = 0;
source.forEach((skill) => {
const selected = selectedSkillIds.includes(skill.skillId);
if (allSelected ? selected : !selected && added < Math.max(0, 8 - selectedCount)) {
onToggle(skill.skillId);
added += 1;
}
});
};
const reasoningLabels: Record<AgentReasoningMode, string> = { off: "直达", auto: "自动推理", deep: "深入推理" };
useEffect(() => {
const target = loadMoreRef.current;
if (tab !== "market" || !target || !hasMore || loading || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) void onLoadMore();
}, { root: listRef.current, rootMargin: "120px 0px" });
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, loading, onLoadMore, tab]);
function reasoningModeLabel(mode: AgentReasoningMode) { return reasoningLabels[mode]; }
return (
<div className="canvas-agent-skill-picker" data-canvas-no-zoom data-canvas-wheel-scroll>
<div className="canvas-agent-skill-picker-header flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2.5 text-base font-semibold"><Sparkles className="size-5" />选择 Skills</div>
<div className="mt-1.5 text-xs" style={{ color: theme.node.muted }}>本轮启用 {selectedCount}/8 个,发送时固定版本</div>
</div>
<div className="min-w-14 text-right">
{tab === "installed" && source.length > 0 ? <button type="button" className="rounded-lg px-2 py-1 text-xs font-medium" style={{ color: theme.accent.primary }} onClick={toggleAll}>{allSelected ? "取消全选" : "全选"}</button> : null}
</div>
</div>
<div className="canvas-agent-skill-picker-tabs flex gap-1.5 rounded-xl p-1.5" style={{ background: theme.node.fill }}>
<button type="button" className="flex-1 rounded-lg px-3 text-sm font-medium" style={{ background: tab === "installed" ? theme.toolbar.itemHover : "transparent", color: tab === "installed" ? theme.node.text : theme.node.muted }} onClick={() => setTab("installed")} aria-pressed={tab === "installed"}>已加入 <span className="ml-1 opacity-60">{installedSkills.length}</span></button>
<button type="button" className="flex-1 rounded-lg px-3 text-sm font-medium" style={{ background: tab === "market" ? theme.toolbar.itemHover : "transparent", color: tab === "market" ? theme.node.text : theme.node.muted }} onClick={() => setTab("market")} aria-pressed={tab === "market"}>发现 Skills</button>
</div>
<Input allowClear prefix={<Search className="size-4" style={{ color: theme.node.muted }} />} value={search} onChange={(event) => onSearch(event.target.value)} placeholder={tab === "market" ? "搜索技能市场" : "搜索已加入 Skills"} aria-label="搜索 Skills" className="canvas-agent-skill-picker-search !rounded-xl" />
<div ref={listRef} className="canvas-agent-skill-picker-list thin-scrollbar space-y-2 overflow-y-auto">
{loading && source.length === 0 ? <div className="grid h-full place-items-center text-xs" style={{ color: theme.node.muted }}><span className="inline-flex items-center gap-2"><LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />正在读取技能…</span></div> : null}
{source.map((skill) => <SkillPickerRow key={skill.skillId} skill={skill} theme={theme} selected={selectedSkillIds.includes(skill.skillId)} canSelect={selectedCount < 8 || selectedSkillIds.includes(skill.skillId)} onToggle={() => onToggle(skill.skillId)} onInstall={() => void onInstall(skill)} />)}
{!loading && source.length === 0 ? <div className="grid h-full place-items-center px-8 text-center text-xs leading-5" style={{ color: theme.node.muted }}>{tab === "market" ? "没有匹配的公开 Skill,换个关键词试试" : "还没有加入 Skill,可前往“发现 Skills”添加"}</div> : null}
{tab === "market" && hasMore ? <div ref={loadMoreRef} className="h-px" aria-hidden /> : null}
</div>
<div className="canvas-agent-skill-picker-footer flex items-center justify-between gap-3 border-t text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
<span>{tab === "installed" ? `已选择 ${selectedCount} 个技能包` : `已加载 ${marketSkills.length} 个技能包`}</span>
{tab === "market" && hasMore ? <button type="button" className="rounded-lg px-3 py-1.5 font-medium" style={{ background: theme.node.fill, color: theme.node.text }} disabled={loading} onClick={() => void onLoadMore()}>{loading ? "加载中…" : "加载更多"}</button> : <span>{tab === "installed" ? "最多 8 个" : "已加载全部"}</span>}
</div>
</div>
);
function reasoningMenuItems(mode: AgentReasoningMode, onChange: (value: AgentReasoningMode) => void) {
return (Object.keys(reasoningLabels) as AgentReasoningMode[]).map((value) => ({
key: value,
label: reasoningLabels[value],
icon: value === mode ? <Check className="size-3.5" /> : undefined,
onClick: () => onChange(value),
}));
}
function SkillPickerRow({ skill, theme, selected, canSelect, onToggle, onInstall }: { skill: Skill; theme: CanvasTheme; selected: boolean; canSelect: boolean; onToggle: () => void; onInstall: () => void }) {
const [expanded, setExpanded] = useState(false);
const description = skill.description || "暂无技能说明";
const hasMore = description.length > 112 || description.includes("\n");
return (
<div className={`canvas-agent-skill-picker-row flex min-w-0 items-start gap-3.5 rounded-xl border ${selected ? "is-selected" : ""}`} style={{ borderColor: selected ? theme.accent.primary : theme.node.stroke }} title={skill.description || skill.skillName}>
{skill.isAdded ? (
<button
type="button"
role="checkbox"
aria-checked={selected}
disabled={!selected && !canSelect}
className="canvas-agent-skill-picker-check mt-0.5 grid shrink-0 place-items-center rounded-md border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/30 disabled:cursor-not-allowed disabled:opacity-35"
style={{ borderColor: selected ? theme.accent.primary : theme.node.stroke, background: selected ? theme.accent.primary : "transparent", color: selected ? theme.toolbar.panel : theme.node.muted }}
onClick={onToggle}
aria-label={`${selected ? "停用" : "启用"} ${skill.skillName}`}
>
{selected ? <Check className="size-3.5" strokeWidth={3} /> : null}
</button>
) : <span className="canvas-agent-skill-picker-check mt-0.5 grid shrink-0 place-items-center rounded-md" style={{ background: theme.node.fill, color: theme.node.muted }}><Sparkles className="size-3.5" /></span>}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-3"><span className="min-w-0 flex-1 break-words text-sm font-semibold [overflow-wrap:anywhere]">{skill.skillName}</span>{skill.version ? <span className="shrink-0 text-xs" style={{ color: theme.node.muted }}>v{skill.version}</span> : null}</div>
<div className={`mt-1 break-words text-xs leading-5 ${expanded ? "" : "line-clamp-2"}`} style={{ color: theme.node.muted }}>{description}</div>
</div>
{!skill.isAdded ? <Button size="small" type="text" className="!h-8 !shrink-0 !rounded-lg !px-2 !text-xs" icon={<Plus className="size-3.5" />} onClick={onInstall}>加入</Button> : null}
{hasMore ? (
<button type="button" className="canvas-agent-skill-picker-expand shrink-0 self-end text-[var(--fs-label)]" style={{ color: theme.accent.primary }} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}>
{expanded ? "收起" : "查看全部"}
</button>
) : null}
</div>
);
}
function ApprovalCard({ approval, theme, submitting, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onReasonChange: (value: string) => void; onApprove: () => void; onReject: () => void }) {
function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: () => void; onReject: () => void }) {
const [showReason, setShowReason] = useState(Boolean(approval.reason));
const action = agentApprovalPresentation(approval.detail);
return (
@@ -1077,7 +1000,11 @@ function ApprovalCard({ approval, theme, submitting, onReasonChange, onApprove,
<span className="canvas-agent-approval-badge">等待你的确认</span>
</div>
<p className="canvas-agent-approval-description" style={{ color: theme.node.muted }}>{action.description}</p>
{action.items.length ? <div className="canvas-agent-approval-items" aria-label="涉及节点">{action.items.map((item, index) => <span key={`${item}-${index}`} title={item}>{item}</span>)}</div> : null}
{action.items.length ? (
<div className="canvas-agent-approval-items" aria-label="涉及节点">
{action.items.map((item, index) => <ApprovalPreviewItemView key={`${item.operation}-${item.nodeId || item.nodeTitle || index}-${index}`} item={item} theme={theme} onFocusNode={onFocusNode} />)}
</div>
) : <div className="canvas-agent-approval-empty" style={{ color: theme.node.muted }}>无法确认具体目标,继续前请重新读取画布。</div>}
<button type="button" className="canvas-agent-approval-reason-toggle" aria-expanded={showReason} onClick={() => setShowReason((value) => !value)} disabled={submitting}>
{showReason ? "收起拒绝理由" : "填写拒绝理由(可选)"}
</button>
@@ -1093,6 +1020,37 @@ function ApprovalCard({ approval, theme, submitting, onReasonChange, onApprove,
);
}
function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType<typeof agentApprovalPresentation>["items"][number]; theme: CanvasTheme; onFocusNode?: (nodeId: string) => void }) {
const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : "生成";
const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => {
if (!title) return null;
const content = <><span className="canvas-agent-approval-node-title">{title}</span>{typeLabel ? <span className="canvas-agent-approval-node-type">{typeLabel}</span> : null}</>;
return id && onFocusNode ? <button type="button" className="canvas-agent-approval-node canvas-agent-approval-node-button" onClick={() => onFocusNode(id)} title="定位到画布节点">{content}</button> : <span className={`canvas-agent-approval-node canvas-agent-approval-node-${role}`}>{content}</span>;
};
return (
<article className="canvas-agent-approval-item">
<div className="canvas-agent-approval-item-main">
<span className={`canvas-agent-approval-operation canvas-agent-approval-operation-${item.operation}`}>{operationLabel}</span>
{item.operation === "connect_nodes" ? (
<div className="canvas-agent-approval-connection">
{renderNode(item.nodeTitle, item.nodeId, item.nodeTypeLabel, "source")}
<span className="canvas-agent-approval-arrow" aria-hidden="true">→</span>
{renderNode(item.targetNodeTitle, item.targetNodeId, undefined, "target")}
</div>
) : (
<div className="canvas-agent-approval-node-summary">
{renderNode(item.nodeTitle, item.nodeId, item.nodeTypeLabel, "node")}
{item.resultTitle ? <><span className="canvas-agent-approval-change-arrow" aria-hidden="true">改为</span><span className="canvas-agent-approval-result-title">《{item.resultTitle}》</span></> : null}
</div>
)}
</div>
{item.fields?.length ? <div className="canvas-agent-approval-field-list">修改:{item.fields.map((field) => <span key={field}>{field}</span>)}</div> : null}
{item.details?.length ? <div className="canvas-agent-approval-detail-list">{item.details.map((detail) => <span key={detail}>{detail}</span>)}</div> : null}
<div className="canvas-agent-approval-summary">{item.summary}</div>
</article>
);
}
function positiveNumber(value: string) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : undefined;

View File

@@ -1,23 +1,31 @@
import { useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { Button, Checkbox, Input } from "antd";
import { ArrowLeft, Check, ChevronRight, Cpu, Gauge, LockKeyhole, PlugZap, Search, ShieldCheck, Sparkles, Wrench } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { AgentPermissionMode, AgentPersonality } from "@/services/api/agent";
import type { AgentPermissionMode, AgentProfileLayer, AgentProfileScope, AgentProfileView, AgentReasoningMode } from "@/services/api/agent";
import type { Skill } from "@/services/api/skills";
import type { AiConfig } from "@/stores/use-config-store";
export type AgentContextKey = "canvas" | "resources" | "generation_history" | "skills" | "project";
type SettingsSection = "home" | "skills" | "mcp" | "context" | "budget";
type SettingsSection = "home" | "profile" | "skills" | "mcp" | "context" | "budget";
type AgentSettingsProps = {
theme: CanvasTheme;
config: AiConfig;
selectedModel: string;
permissionMode: AgentPermissionMode;
personality: AgentPersonality;
onPersonalityChange: (value: AgentPersonality) => void;
reasoningMode: AgentReasoningMode;
onReasoningModeChange: (value: AgentReasoningMode) => void;
profileView: AgentProfileView | null;
profileLoading: boolean;
profileSaving: boolean;
profileError?: string;
projectId?: string;
canvasId: string;
onReloadProfile: () => Promise<void>;
onSaveProfile: (input: { scope: AgentProfileScope; projectId?: string; canvasId?: string; content: string; revision: number }) => Promise<AgentProfileView>;
contextScope: AgentContextKey[];
nodeCount: number;
installedSkills: Skill[];
@@ -52,6 +60,12 @@ const contextOptions: Array<{ value: AgentContextKey; label: string; description
{ value: "canvas", label: "已保存画布摘要", description: "最多 80 个节点的标题与文本片段,不含媒体正文或未同步修改" },
];
const reasoningOptions: Array<{ value: AgentReasoningMode; label: string; description: string }> = [
{ value: "off", label: "关闭", description: "直接执行" },
{ value: "auto", label: "自动", description: "按任务决定" },
{ value: "deep", label: "深入", description: "复杂任务优先规划" },
];
export function CanvasCloudAgentSettings(props: AgentSettingsProps) {
const [section, setSection] = useState<SettingsSection>("home");
const [skillTab, setSkillTab] = useState<"installed" | "market">("installed");
@@ -68,6 +82,7 @@ export function CanvasCloudAgentSettings(props: AgentSettingsProps) {
</header>
{section === "home" ? <SettingsHome props={props} theme={theme} onOpen={setSection} /> : null}
{section === "profile" ? <ProfileWorkspace props={props} theme={theme} /> : null}
{section === "skills" ? <SkillsWorkspace props={props} theme={theme} tab={skillTab} onTabChange={setSkillTab} /> : null}
{section === "mcp" ? <McpWorkspace theme={theme} /> : null}
{section === "context" ? <ContextWorkspace props={props} theme={theme} /> : null}
@@ -80,8 +95,9 @@ function SettingsHome({ props, theme, onOpen }: { props: AgentSettingsProps; the
return (
<div className="canvas-agent-settings-scroll thin-scrollbar min-h-0 flex-1 space-y-6 overflow-y-auto p-4">
<section>
<SettingLabel label="表达风格" hint="新运行生效" />
<div className="grid grid-cols-2 gap-2">{([['务实','先说结果,少铺垫'],['简洁','只保留必要信息'],['详细','补充步骤与依据'],['温和','更耐心地解释']] as const).map(([value, description]) => <button key={value} type="button" aria-pressed={props.personality === value} onClick={() => props.onPersonalityChange(value)} className="rounded-xl p-3 text-left" style={{ background: props.personality === value ? theme.toolbar.itemHover : theme.node.fill, border: `1px solid ${props.personality === value ? theme.accent.primary : 'transparent'}` }}><div className="text-xs font-semibold">{value}</div><div className="mt-1 text-[10px] opacity-50">{description}</div></button>)}</div>
<SettingLabel label="推理模式" hint="新运行生效" />
<div className="grid grid-cols-3 gap-2">{reasoningOptions.map((option) => <button key={option.value} type="button" aria-pressed={props.reasoningMode === option.value} onClick={() => props.onReasoningModeChange(option.value)} className="rounded-xl p-3 text-left" style={{ background: props.reasoningMode === option.value ? theme.toolbar.itemHover : theme.node.fill, border: `1px solid ${props.reasoningMode === option.value ? theme.accent.primary : 'transparent'}` }}><div className="text-xs font-semibold">{option.label}</div><div className="mt-1 text-[10px] opacity-50">{option.description}</div></button>)}</div>
<p className="mt-2 text-xs leading-5" style={{ color: theme.node.muted }}>推理只用于目标拆解、缺口判断和下一步工具选择,不会把内部思考过程展示给用户。</p>
</section>
<section>
<SettingLabel label="对话模型" hint="新运行生效" />
@@ -109,6 +125,7 @@ function SettingsHome({ props, theme, onOpen }: { props: AgentSettingsProps; the
<SettingLabel label="能力与范围" />
<div className="space-y-1">
<SettingRow theme={theme} icon={<Cpu className="size-4" />} title="上下文" summary={`${props.nodeCount} 个节点 · ${props.contextScope.length} 个范围`} onClick={() => onOpen("context")} />
<SettingRow theme={theme} icon={<Sparkles className="size-4" />} title="长期偏好" summary={profileSummary(props.profileView)} onClick={() => onOpen("profile")} />
<SettingRow theme={theme} icon={<Sparkles className="size-4" />} title="Skills · 用户技能库" summary={`${props.installedSkills.length} 个已安装 · 本轮启用 ${props.selectedSkillIds.length} 个`} onClick={() => onOpen("skills")} />
<SettingRow theme={theme} icon={<Wrench className="size-4" />} title="工具与连接" summary="画布、技能参考文件、生成任务" onClick={() => onOpen("mcp")} />
<SettingRow theme={theme} icon={<Gauge className="size-4" />} title="预算" summary={`每轮最多 ${props.maxCredits || "未设置"} 积分 · 固定计价模型`} onClick={() => onOpen("budget")} />
@@ -146,6 +163,104 @@ function SkillsWorkspace({ props, theme, tab, onTabChange }: { props: AgentSetti
);
}
type ProfileDraft = Pick<AgentProfileLayer, "content" | "revision" | "hash"> & { dirty: boolean };
function ProfileWorkspace({ props, theme }: { props: AgentSettingsProps; theme: CanvasTheme }) {
const [scope, setScope] = useState<AgentProfileScope>("user");
const [drafts, setDrafts] = useState<Record<string, ProfileDraft>>({});
const [error, setError] = useState("");
const layer = profileLayer(props.profileView, scope, props.projectId, props.canvasId);
const draftKey = profileDraftKey(scope, props.projectId, props.canvasId);
const serverDraft: ProfileDraft = { content: layer.content, revision: layer.revision, hash: layer.hash, dirty: false };
const draft = drafts[draftKey] || serverDraft;
const profileUnavailable = props.profileLoading || !props.profileView || Boolean(props.profileError);
useEffect(() => {
setDrafts((current) => {
const existing = current[draftKey];
if (existing?.dirty) return current;
if (existing && existing.revision === layer.revision && existing.hash === layer.hash && existing.content === layer.content) return current;
return { ...current, [draftKey]: { content: layer.content, revision: layer.revision, hash: layer.hash, dirty: false } };
});
setError("");
}, [draftKey, layer.content, layer.hash, layer.revision]);
const updateDraft = (content: string) => {
setDrafts((current) => {
const base = current[draftKey] || serverDraft;
return { ...current, [draftKey]: { ...base, content, dirty: true } };
});
};
const save = async () => {
setError("");
try {
const result = await props.onSaveProfile({
scope,
projectId: scope === "project" ? props.projectId : undefined,
canvasId: scope === "canvas" ? props.canvasId : undefined,
content: draft.content,
revision: draft.revision,
});
const savedLayer = profileLayer(result, scope, props.projectId, props.canvasId);
setDrafts((current) => ({
...current,
[draftKey]: { content: savedLayer.content, revision: savedLayer.revision, hash: savedLayer.hash, dirty: false },
}));
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
};
const disabled = profileUnavailable || props.profileSaving || (scope === "project" && !props.projectId);
return (
<div className="canvas-agent-settings-scroll thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="rounded-xl p-4" style={{ background: theme.node.fill }}>
<div className="text-sm font-semibold">长期偏好文档</div>
<p className="mt-2 text-xs leading-5" style={{ color: theme.node.muted }}>这里定义 Agent 的长期工作习惯、语气和输出结构。它不会增加工具、节点、预算或审批权限;安全契约始终由服务端代码控制。</p>
</div>
<div className="mt-4 flex gap-1 rounded-xl p-1" style={{ background: theme.node.fill }}>
<TabButton theme={theme} active={scope === "user"} label="用户" onClick={() => setScope("user")} />
<TabButton theme={theme} active={scope === "project"} label="项目" onClick={() => setScope("project")} />
<TabButton theme={theme} active={scope === "canvas"} label="画布" onClick={() => setScope("canvas")} />
</div>
{scope === "project" && !props.projectId ? <p className="mt-3 text-xs leading-5" style={{ color: theme.node.muted }}>当前画布尚未关联项目,项目偏好暂不可用。</p> : null}
{props.profileLoading ? <p className="mt-4 text-xs" style={{ color: theme.node.muted }}>正在读取偏好快照…</p> : null}
{props.profileError ? <ProfileError message={props.profileError} onRetry={props.onReloadProfile} theme={theme} /> : null}
<label className="mt-4 block">
<span className="flex items-center justify-between text-xs font-semibold"><span>{scopeLabel(scope)}</span><span className="text-[10px] font-normal opacity-40">修订 {draft.revision}{draft.dirty ? " · 未保存" : ""}</span></span>
<Input.TextArea value={draft.content} onChange={(event) => updateDraft(event.target.value)} disabled={disabled} maxLength={12000} showCount autoSize={{ minRows: 10, maxRows: 20 }} placeholder="例如:每次先给结论,再列出将改动的镜头;提示词同时给中文和英文。" className="mt-2 !rounded-xl" style={{ background: theme.node.fill }} />
</label>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-[10px] leading-4 opacity-45">保存后只影响下一次新运行。正在运行的 Agent 会固定原快照。</span>
<Button type="primary" onClick={() => void save()} loading={props.profileSaving} disabled={disabled || !draft.dirty}>保存</Button>
</div>
{error ? <div className="mt-3 rounded-lg px-3 py-2 text-xs" style={{ background: "#d66b6b1f", color: "#d66b6b" }}>{error}</div> : null}
</div>
);
}
function profileDraftKey(scope: AgentProfileScope, projectId: string | undefined, canvasId: string) {
if (scope === "project") return `project:${projectId || "unbound"}`;
if (scope === "canvas") return `canvas:${canvasId}`;
return "user";
}
function profileLayer(view: AgentProfileView | null, scope: AgentProfileScope, projectId: string | undefined, canvasId: string): AgentProfileLayer {
return view?.layers.find((item) => item.scope === scope) || { scope, projectId: scope === "project" ? projectId : undefined, canvasId: scope === "canvas" ? canvasId : undefined, content: "", revision: 0, hash: "" };
}
function profileSummary(view: AgentProfileView | null) {
const count = view?.layers.filter((layer) => layer.content.trim()).length || 0;
return count ? `${count} 层已配置 · 仅影响行为偏好` : "未配置 · 仅影响行为偏好";
}
function scopeLabel(scope: AgentProfileScope) { return scope === "user" ? "用户偏好" : scope === "project" ? "项目偏好" : "当前画布偏好"; }
function ProfileError({ message, onRetry, theme }: { message: string; onRetry: () => Promise<void>; theme: CanvasTheme }) {
return <div className="mt-3 rounded-lg px-3 py-2 text-xs" style={{ background: "#d6a24a1f", color: theme.node.text }}><div>{message}</div><button type="button" className="mt-2 underline" onClick={() => void onRetry()}>重新读取</button></div>;
}
function SkillRow({ skill, theme, selected, installed, selectable, onToggle, onInstall }: { skill: Skill; theme: CanvasTheme; selected: boolean; installed: boolean; selectable: boolean; onToggle: () => void; onInstall: () => void }) {
return (
<div className="canvas-agent-skill-row flex min-w-0 items-start gap-3 rounded-xl p-3 transition-colors">
@@ -187,8 +302,8 @@ function SettingRow({ theme, icon, title, summary, onClick }: { theme: CanvasThe
function TabButton({ active, label, onClick, theme }: { active: boolean; label: string; onClick: () => void; theme: CanvasTheme }) { return <button type="button" aria-pressed={active} className="min-w-0 flex-1 rounded-lg px-2 py-2 text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2" style={{ background: active ? theme.toolbar.itemHover : "transparent", color: active ? theme.node.text : theme.node.muted }} onClick={onClick}>{label}</button>; }
function SettingLabel({ label, hint }: { label: string; hint?: string }) { return <div className="mb-2 flex items-center justify-between text-xs font-semibold"><span>{label}</span>{hint ? <span className="text-[10px] font-normal opacity-40">{hint}</span> : null}</div>; }
function BudgetInput({ label, hint, value, onChange, theme }: { label: string; hint: string; value: string; onChange: (value: string) => void; theme: CanvasTheme }) { return <label className="block"><span className="flex items-center justify-between text-xs font-medium"><span>{label}</span><span className="text-[10px] opacity-40">{hint}</span></span><Input size="large" value={value} onChange={(event) => onChange(event.target.value.replace(/[^0-9]/g, ""))} inputMode="numeric" className="mt-2 !rounded-lg" style={{ background: theme.node.fill }} /></label>; }
function sectionTitle(section: SettingsSection) { return section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; }
function sectionSubtitle(section: SettingsSection) { return section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
function sectionTitle(section: SettingsSection) { return section === "profile" ? "长期偏好" : section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; }
function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
export function agentPermissionLabel(mode: AgentPermissionMode) { return permissionOptions.find((option) => option.value === mode)?.label || "请求审批"; }
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f` }; }

View File

@@ -131,6 +131,18 @@
outline: none;
}
.agent-composer-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 8px;
}
.agent-composer-submit {
min-width: 0;
justify-self: end;
}
.agent-composer-send-hint {
color: color-mix(in srgb, currentColor 52%, transparent);
font-size: var(--fs-tiny);
@@ -138,7 +150,8 @@
white-space: nowrap;
}
@container (max-width: 420px) {
/* 面板宽度包含内边距;默认 448px 面板的输入区已不足以同时容纳控制区和快捷键提示。 */
@container (max-width: 520px) {
.agent-composer-send-hint {
display: none;
}
@@ -243,24 +256,137 @@
}
.canvas-agent-approval-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
display: grid;
gap: 8px;
max-height: min(360px, 42vh);
margin-top: 12px;
overflow-y: auto;
padding-right: 2px;
}
.canvas-agent-approval-items span {
max-width: 100%;
overflow: hidden;
border-radius: 6px;
background: color-mix(in srgb, var(--foreground) 7%, transparent);
padding: 4px 7px;
font-size: 11px;
.canvas-agent-approval-item {
display: grid;
gap: 6px;
min-width: 0;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 9px 10px;
}
.canvas-agent-approval-item-main,
.canvas-agent-approval-node-summary,
.canvas-agent-approval-connection {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.canvas-agent-approval-operation {
flex: 0 0 auto;
border-radius: 5px;
padding: 2px 6px;
font-size: 10px;
font-weight: 700;
line-height: 16px;
}
.canvas-agent-approval-operation-add_node { background: #dcfce7; color: #166534; }
.canvas-agent-approval-operation-update_node { background: #dbeafe; color: #1d4ed8; }
.canvas-agent-approval-operation-connect_nodes { background: #fef3c7; color: #92400e; }
.canvas-agent-approval-operation-generate_media { background: #f3e8ff; color: #7e22ce; }
.canvas-agent-approval-node,
.canvas-agent-approval-node-button {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
border-radius: 6px;
color: var(--foreground);
font-size: 12px;
line-height: 18px;
text-align: left;
}
.canvas-agent-approval-node-button {
cursor: pointer;
padding: 2px 5px;
}
.canvas-agent-approval-node-button:hover {
background: color-mix(in srgb, var(--agent-approval-blue) 10%, transparent);
color: var(--agent-approval-blue);
}
.canvas-agent-approval-node-title,
.canvas-agent-approval-result-title {
min-width: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-agent-approval-node-type {
flex: 0 0 auto;
border-radius: 4px;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
padding: 1px 5px;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
font-size: 10px;
line-height: 15px;
}
.canvas-agent-approval-arrow,
.canvas-agent-approval-change-arrow {
flex: 0 0 auto;
color: color-mix(in srgb, var(--foreground) 48%, transparent);
font-size: 12px;
}
.canvas-agent-approval-result-title {
color: var(--agent-approval-blue);
font-size: 12px;
}
.canvas-agent-approval-field-list,
.canvas-agent-approval-detail-list,
.canvas-agent-approval-summary,
.canvas-agent-approval-empty {
color: color-mix(in srgb, var(--foreground) 62%, transparent);
font-size: 11px;
line-height: 17px;
}
.canvas-agent-approval-field-list,
.canvas-agent-approval-detail-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.canvas-agent-approval-field-list span,
.canvas-agent-approval-detail-list span {
border-radius: 4px;
background: color-mix(in srgb, var(--foreground) 7%, transparent);
padding: 2px 5px;
}
.canvas-agent-approval-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-agent-approval-empty {
margin-top: 12px;
border-radius: 8px;
background: color-mix(in srgb, #f59e0b 10%, transparent);
padding: 8px 10px;
}
.canvas-agent-approval-reason-toggle {
display: inline-flex;
margin-top: 10px;
@@ -356,82 +482,486 @@
gap: 8px;
}
.canvas-agent-skill-popover .ant-popover-inner {
padding: 0;
.canvas-agent-skill-library-modal .ant-modal-container {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 18px;
border-radius: var(--r-2xl);
padding: 0;
background: transparent;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18), var(--elevation-hairline) !important;
}
.canvas-agent-skill-picker {
.canvas-agent-skill-library-modal .ant-modal-body {
padding: 0;
}
.canvas-agent-skill-library-modal .ant-modal-close {
top: 16px;
right: 16px;
width: 34px;
height: 34px;
border-radius: var(--r-md);
color: var(--foreground);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
}
.canvas-agent-skill-library-modal .ant-modal-close:hover {
background: color-mix(in srgb, var(--foreground) 12%, transparent);
}
.canvas-agent-skill-library-shell {
position: relative;
display: grid;
height: min(780px, calc(100dvh - 36px));
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
overflow: hidden;
border-radius: inherit;
background:
radial-gradient(circle at 0 0, color-mix(in srgb, var(--workspace-accent) 7%, transparent), transparent 34%),
var(--cn-spatial-elevated, var(--background));
}
.canvas-agent-skill-library-header {
display: flex;
width: min(520px, calc(100vw - 24px));
max-height: calc(100vh - 24px);
flex-direction: column;
padding: 12px;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 18px 64px 14px 20px;
}
.canvas-agent-skill-picker-tabs {
height: 48px;
margin-top: 10px;
.canvas-agent-skill-library-header p {
margin-bottom: 0;
}
.canvas-agent-skill-picker-tabs button {
transition: background-color 140ms ease, color 140ms ease;
.canvas-agent-skill-library-count {
flex: 0 0 auto;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border-radius: var(--r-lg);
background: color-mix(in srgb, var(--foreground) 4%, transparent);
padding: 7px 10px;
font-size: var(--fs-label);
font-variant-numeric: tabular-nums;
}
.canvas-agent-skill-picker-search {
height: 44px;
margin-top: 10px;
.canvas-agent-skill-library-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 430px);
align-items: center;
gap: 14px;
padding: 0 20px 12px;
}
.canvas-agent-skill-picker-list {
.canvas-agent-skill-library-tabs {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.canvas-agent-skill-library-tab {
display: inline-flex;
min-height: 38px;
flex: 0 0 auto;
align-items: center;
gap: 7px;
border-radius: var(--r-md);
padding: 0 12px;
color: color-mix(in srgb, var(--foreground) 56%, transparent);
font-size: var(--fs-caption);
font-weight: 560;
transition: background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
}
.canvas-agent-skill-library-tab span {
min-width: 20px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 9%, transparent);
padding: 1px 6px;
font-size: var(--fs-tiny);
line-height: 18px;
text-align: center;
}
.canvas-agent-skill-library-tab:hover {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
color: color-mix(in srgb, var(--foreground) 76%, transparent);
}
.canvas-agent-skill-library-tab.is-active {
background: color-mix(in srgb, var(--foreground) 5%, transparent);
color: color-mix(in srgb, var(--foreground) 78%, transparent);
}
.canvas-agent-skill-library-search {
height: 40px;
border-radius: var(--r-lg);
}
.canvas-agent-skill-library-categories {
display: flex;
min-width: 0;
gap: 8px;
overflow-x: auto;
border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
padding: 10px 20px;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.canvas-agent-skill-library-categories::-webkit-scrollbar {
display: none;
}
.canvas-agent-skill-category {
min-height: 34px;
flex: 0 0 auto;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border-radius: var(--r-md);
padding: 0 13px;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
font-size: var(--fs-caption);
transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
}
.canvas-agent-skill-category:hover {
border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
color: var(--foreground);
}
.canvas-agent-skill-category.is-active {
border-color: color-mix(in srgb, var(--workspace-accent) 34%, var(--border));
background: color-mix(in srgb, var(--workspace-accent) 11%, transparent);
color: var(--workspace-accent);
}
.canvas-agent-skill-library-list {
display: grid;
min-height: 0;
max-height: min(360px, 44vh);
margin-top: 10px;
padding-right: 5px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: max-content;
align-content: start;
gap: 12px;
overflow-y: auto;
padding: 16px 20px;
overscroll-behavior: contain;
}
.canvas-agent-skill-picker-row {
padding: 8px 10px;
transition: background-color 140ms ease, border-color 140ms ease;
.canvas-agent-skill-library-state {
display: grid;
min-height: 260px;
grid-column: 1 / -1;
place-items: center;
align-content: center;
gap: 10px;
padding: 28px;
font-size: var(--fs-caption);
text-align: center;
}
.canvas-agent-skill-picker-row:hover {
background: var(--library-surface-hover);
.canvas-agent-skill-card {
display: grid;
min-width: 0;
min-height: 136px;
grid-template-columns: 164px minmax(0, 1fr) auto;
gap: 14px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--r-xl);
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 11px;
transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out);
}
.canvas-agent-skill-picker-row.is-selected {
.canvas-agent-skill-card:hover {
border-color: color-mix(in srgb, var(--foreground) 22%, transparent) !important;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
transform: translateY(-1px);
}
.canvas-agent-skill-card.is-selected {
background: color-mix(in srgb, var(--workspace-accent) 7%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 24%, transparent);
}
.canvas-agent-skill-picker-check {
width: 22px;
height: 22px;
.canvas-agent-skill-card-cover {
position: relative;
min-width: 0;
overflow: hidden;
border-radius: var(--r-lg);
aspect-ratio: 16 / 10;
}
.canvas-agent-skill-picker-footer {
min-height: 36px;
margin-top: 8px;
padding-top: 8px;
.canvas-agent-skill-card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--motion-dur-base) var(--motion-ease-out);
}
.canvas-agent-skill-picker-expand {
border-radius: 5px;
padding: 2px 4px;
.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
transform: scale(1.025);
}
.canvas-agent-skill-picker-expand:hover {
background: color-mix(in srgb, var(--workspace-accent) 10%, transparent);
.canvas-agent-skill-card-placeholder {
display: grid;
width: 100%;
height: 100%;
place-items: center;
color: color-mix(in srgb, var(--workspace-accent) 64%, var(--foreground));
background:
radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--workspace-accent) 28%, transparent), transparent 46%),
linear-gradient(145deg, color-mix(in srgb, var(--foreground) 8%, transparent), color-mix(in srgb, var(--foreground) 2%, transparent));
}
.canvas-agent-skill-card-category {
position: absolute;
top: 7px;
right: 7px;
max-width: calc(100% - 14px);
overflow: hidden;
border: 1px solid color-mix(in srgb, white 18%, transparent);
border-radius: var(--r-md);
background: color-mix(in srgb, black 62%, transparent);
padding: 2px 7px;
color: white;
font-size: var(--fs-tiny);
line-height: 19px;
text-overflow: ellipsis;
white-space: nowrap;
backdrop-filter: blur(8px);
}
.canvas-agent-skill-card-body {
display: flex;
min-width: 0;
flex-direction: column;
padding: 4px 0 2px;
}
.canvas-agent-skill-card-title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
}
.canvas-agent-skill-card-title-row h3 {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--foreground);
font-size: 15px;
font-weight: 650;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-agent-skill-card-version {
flex: 0 0 auto;
color: color-mix(in srgb, var(--foreground) 42%, transparent);
font-size: var(--fs-tiny);
}
.canvas-agent-skill-card-body > p {
display: -webkit-box;
min-width: 0;
overflow: hidden;
margin: 7px 0 10px;
color: color-mix(in srgb, var(--foreground) 54%, transparent);
font-size: var(--fs-label);
line-height: 19px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.canvas-agent-skill-card-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
margin-top: auto;
font-size: var(--fs-label);
}
.canvas-agent-skill-card-action {
display: flex;
align-items: flex-start;
padding-top: 3px;
}
.canvas-agent-skill-card-action .ant-btn {
min-width: 76px;
border-radius: var(--r-md);
}
.canvas-agent-skill-card-action .ant-btn.is-selected {
border-color: color-mix(in srgb, var(--workspace-accent) 32%, var(--border));
background: color-mix(in srgb, var(--workspace-accent) 13%, transparent);
color: var(--workspace-accent);
box-shadow: none;
}
.canvas-agent-skill-library-footer {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: 16px;
border-top: 1px solid;
padding: 8px 20px;
font-size: var(--fs-label);
}
.canvas-agent-skill-library-tab:focus-visible,
.canvas-agent-skill-category:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (max-width: 920px) {
.canvas-agent-skill-library-list {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 680px) {
.canvas-agent-skill-library-shell {
height: calc(100dvh - 24px);
}
.canvas-agent-skill-library-header {
align-items: flex-start;
gap: 10px;
padding: 16px 56px 12px 14px;
}
.canvas-agent-skill-library-header p {
display: none;
}
.canvas-agent-skill-library-count {
padding: 5px 8px;
}
.canvas-agent-skill-library-toolbar {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 0 14px 10px;
}
.canvas-agent-skill-library-tabs {
overflow-x: auto;
scrollbar-width: none;
}
.canvas-agent-skill-library-tabs::-webkit-scrollbar {
display: none;
}
.canvas-agent-skill-library-categories {
padding: 8px 14px;
}
.canvas-agent-skill-library-list {
gap: 10px;
padding: 12px 14px;
}
.canvas-agent-skill-library-footer {
min-height: 44px;
padding: 6px 14px;
}
}
@media (max-width: 560px) {
.canvas-agent-skill-library-header {
flex-wrap: wrap;
}
.canvas-agent-skill-library-count {
position: absolute;
top: 17px;
right: 56px;
}
.canvas-agent-skill-library-tab {
min-height: 34px;
padding: 0 9px;
font-size: var(--fs-label);
}
.canvas-agent-skill-card {
min-height: 122px;
grid-template-columns: 104px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
gap: 8px 10px;
padding: 9px;
}
.canvas-agent-skill-card-cover {
grid-row: 1 / 3;
align-self: stretch;
aspect-ratio: auto;
}
.canvas-agent-skill-card-body {
padding: 1px 0 0;
}
.canvas-agent-skill-card-body > p {
margin: 4px 0 6px;
font-size: var(--fs-tiny);
line-height: 17px;
}
.canvas-agent-skill-card-meta {
font-size: var(--fs-tiny);
}
.canvas-agent-skill-card-action {
justify-content: flex-end;
padding: 0;
}
.canvas-agent-skill-card-action .ant-btn {
min-width: 72px;
}
.canvas-agent-skill-library-footer > span:last-child {
display: none;
}
}
@media (max-height: 560px) {
.canvas-agent-skill-picker {
max-height: calc(100vh - 16px);
.canvas-agent-skill-library-shell {
height: calc(100dvh - 16px);
}
.canvas-agent-skill-picker-list {
max-height: min(280px, 38vh);
.canvas-agent-skill-library-header {
padding-top: 12px;
padding-bottom: 8px;
}
.canvas-agent-skill-library-categories {
padding-top: 7px;
padding-bottom: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.canvas-agent-skill-card,
.canvas-agent-skill-card-cover img,
.canvas-agent-skill-category,
.canvas-agent-skill-library-tab {
transition: none;
}
.canvas-agent-skill-card:hover,
.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
transform: none;
}
}