feat(agent): 场景胶囊 - 为会话挂载预设技能组合 (#608)

This commit is contained in:
Wyatt_K authored and ddCat committed 2026-09-23 16:15:34 +08:00
1 parent ec81a1b92b
commit 67cbda6cd6
5 files changed
+391 -8

No files matched your search

@@ -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 = [],