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

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