```
feat(agent): 添加技能自动路由功能 - 引入 SkillRouter 实现根据用户消息自动推荐技能 - 在构建引擎时集成技能路由器 - 从对话历史中提取最后一条用户消息作为路由输入 - 为技能添加触发关键词和中文标题字段支持 refactor(browser): 重构浏览器自动化为子进程架构 - 将 Playwright 浏览器控制移至独立的子进程 worker - 解决 PyInstaller 打包环境下 C 扩展兼容性问题 - 通过 JSON RPC 协议与浏览器 worker 通信 - 添加工具目录和脚本路径查找机制 feat(skills): 增强技能元数据和UI展示 - 为技能添加 triggers 和 title 字段 - 在技能商店中包含中文标题信息 - 添加 office-viz 技能优先级排序 - 在服务器管理器中返回技能标题 feat(gui): 实现技能选择器UI组件 - 添加带下拉菜单的技能选择器按钮 - 支持中文标题和拼音首字母显示 - 集成会话技能加载和状态管理 - 提供通用技能选项和已启用技能列表 ```
This commit is contained in:
@@ -1409,6 +1409,7 @@ export interface SessionSkillRow {
|
||||
description: string;
|
||||
scope: "global" | "project";
|
||||
enabled: boolean; // false = muted for this session only
|
||||
title?: string; // 中文标题,用于 UI 显示(如 "办公可视化看板")
|
||||
}
|
||||
|
||||
export interface SkillUploadPreview {
|
||||
|
||||
@@ -179,6 +179,51 @@ export function Composer(props: Props) {
|
||||
setText(`/${s.name} `);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
// 技能选择器状态(独立于 slash popup)
|
||||
const [skillMenuOpen, setSkillMenuOpen] = useState(false);
|
||||
const [skillOptions, setSkillOptions] = useState<SessionSkillRow[]>([]);
|
||||
// 独立存储选中的技能,不受 slash popup 影响
|
||||
const [selectedSkill, setSelectedSkill] = useState<SessionSkillRow | null>(null);
|
||||
|
||||
// 汉字转拼音首字母
|
||||
const pinyinInitials = new Map(Object.entries({
|
||||
"办公可视化看板": "B", "Word文档": "W", "PDF处理": "P",
|
||||
"Excel操作": "E", "PPT制作": "P", "Markdown转换": "M",
|
||||
"通用": "T",
|
||||
}));
|
||||
const getSkillBadge = (name: string, title: string) => {
|
||||
const text = title || name;
|
||||
if (pinyinInitials.has(text)) return pinyinInitials.get(text);
|
||||
// 取中文首字或英文首字母
|
||||
const match = text.match(/[\u4e00-\u9fa5]/);
|
||||
return match ? match[0] : text.slice(0, 1).toUpperCase();
|
||||
};
|
||||
|
||||
// 加载技能列表(打开下拉时)
|
||||
useEffect(() => {
|
||||
if (!skillMenuOpen || skillOptions.length > 0) return;
|
||||
if (!props.sessionId) return;
|
||||
sessionSkills(props.sessionId, props.workspace)
|
||||
.then((all) => setSkillOptions(all.filter((s) => s.enabled)))
|
||||
.catch(() => setSkillOptions([]));
|
||||
}, [skillMenuOpen]);
|
||||
|
||||
// 点击技能选项
|
||||
const handleSelectSkill = (s: SessionSkillRow | null) => {
|
||||
setSelectedSkill(s);
|
||||
if (s) {
|
||||
// 选中具体技能:在文本框插入 slash 命令,触发 slash popup
|
||||
setText(`/${s.name} `);
|
||||
setPendingSkill(s);
|
||||
textareaRef.current?.focus();
|
||||
} else {
|
||||
// 通用(清除技能选择)
|
||||
setPendingSkill(null);
|
||||
setText("");
|
||||
}
|
||||
setSkillMenuOpen(false);
|
||||
};
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
|
||||
// UX-044: which "This session" submenu is open (bindings live server-side).
|
||||
@@ -701,13 +746,70 @@ export function Composer(props: Props) {
|
||||
<span className="text-[12px] text-muted tabular-nums">{recordingTime}</span>
|
||||
</div>
|
||||
) : props.workspace !== undefined ? (
|
||||
<ModeMenu
|
||||
reviewerPaused={props.reviewerPaused}
|
||||
mode={props.mode}
|
||||
onModeChange={props.onModeChange}
|
||||
unattended={props.unattended}
|
||||
onUnattendedChange={props.onUnattendedChange}
|
||||
/>
|
||||
<>
|
||||
<ModeMenu
|
||||
reviewerPaused={props.reviewerPaused}
|
||||
mode={props.mode}
|
||||
onModeChange={props.onModeChange}
|
||||
unattended={props.unattended}
|
||||
onUnattendedChange={props.onUnattendedChange}
|
||||
/>
|
||||
{/* 技能选择器 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-lg text-[12px] text-muted hover:text-ink hover:bg-paper shrink-0 border border-line"
|
||||
onClick={() => setSkillMenuOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={skillMenuOpen}
|
||||
title="选择技能"
|
||||
>
|
||||
{selectedSkill ? (
|
||||
<>
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold bg-accent/10 text-accent">
|
||||
{getSkillBadge(selectedSkill.name, selectedSkill.title || "")}
|
||||
</span>
|
||||
<span className="text-ink">{selectedSkill.title || selectedSkill.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>通用</span>
|
||||
)}
|
||||
<Icon name="chevronDown" size={11} className="text-faint" />
|
||||
</button>
|
||||
{skillMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setSkillMenuOpen(false)} />
|
||||
<div
|
||||
className="absolute z-40 bottom-full mb-1 left-0 w-[220px] rounded-xl border border-line bg-panel shadow-2xl py-1.5"
|
||||
role="menu"
|
||||
>
|
||||
{/* 通用选项 */}
|
||||
<button
|
||||
className="w-full flex items-center px-2.5 py-1.5 text-[13px] text-left hover:bg-paper"
|
||||
onClick={() => handleSelectSkill(null)}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold bg-paper border border-line text-faint mr-2">T</span>
|
||||
<span className="flex-1 text-ink">通用</span>
|
||||
{selectedSkill === null && <span className="text-accent ml-2">✓</span>}
|
||||
</button>
|
||||
<div className="mx-2 my-1 border-t border-line" />
|
||||
{skillOptions.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
className="w-full flex items-center px-2.5 py-1.5 text-left hover:bg-paper"
|
||||
onClick={() => handleSelectSkill(s)}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold bg-accent/10 text-accent mr-2">
|
||||
{getSkillBadge(s.name, s.title || "")}
|
||||
</span>
|
||||
<span className="flex-1 text-[13px] text-ink">{s.title || s.name}</span>
|
||||
{selectedSkill?.name === s.name && <span className="text-accent ml-2">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dictationBusy === t("composer.starting_transcribe") && <span className="text-[12px] text-accent">{dictationBusy}</span>}
|
||||
|
||||
Reference in New Issue
Block a user