```
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled

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:
2026-09-14 17:36:02 +08:00
parent 6f402ffcee
commit 3e356b117a
10 changed files with 1493 additions and 306 deletions

View File

@@ -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 {

View File

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