Commit a9534530 authored by AI-甘富林's avatar AI-甘富林

feat(desktop): add curated skill catalog plumbing

Wire desktop skill catalog resolution through the desktop runtime and packaged app paths.
Add the supporting service layer and trigger plumbing needed for curated skill integration.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent d3ac333b
export interface CuratedGenericSkillDefinition {
id: string;
description: string;
zhName: string;
zhDescription: string;
searchAliases: string[];
}
export const CURATED_GENERIC_SKILLS: CuratedGenericSkillDefinition[] = [
{
id: "xlsx",
description: "Handle spreadsheets, calculations, and data summaries.",
zhName: "表格助手",
zhDescription: "适合处理 Excel、表格计算、数据汇总和清单整理。",
searchAliases: ["表格", "excel", "xlsx", "csv", "数据", "统计", "台账"]
},
{
id: "docx",
description: "Draft Word documents, reports, and polished written materials.",
zhName: "文档助手",
zhDescription: "适合起草 Word 文档、报告、通知和正式材料。",
searchAliases: ["文档", "word", "docx", "报告", "方案", "正文", "材料"]
},
{
id: "pdf",
description: "Read, extract, merge, and organize PDF content.",
zhName: "PDF 助手",
zhDescription: "适合查看、提取、合并和整理 PDF 文件内容。",
searchAliases: ["pdf", "合同", "文件", "扫描件", "提取", "合并"]
},
{
id: "pptx",
description: "Prepare slides, outlines, and presentation materials.",
zhName: "演示助手",
zhDescription: "适合整理 PPT、大纲、汇报页和演示材料。",
searchAliases: ["ppt", "pptx", "演示", "汇报", "课件", "幻灯片"]
},
{
id: "file-skill",
description: "Organize files, folders, batches, and archives safely.",
zhName: "文件整理助手",
zhDescription: "适合做文件分类、批量整理、归档和目录收纳。",
searchAliases: ["文件", "归档", "整理", "重命名", "目录", "批量"]
},
{
id: "note-organizer",
description: "Organize notes, tags, duplicate entries, and knowledge snippets.",
zhName: "笔记整理助手",
zhDescription: "适合梳理会议纪要、学习笔记、标签分类和内容归并。",
searchAliases: ["笔记", "纪要", "标签", "整理", "知识库", "归并"]
},
{
id: "email-skill",
description: "Draft, read, and manage email tasks from one place.",
zhName: "邮件助手",
zhDescription: "适合写邮件、查邮件、整理收件箱和邮件沟通。",
searchAliases: ["邮件", "邮箱", "email", "发信", "收件箱", "沟通"]
},
{
id: "market-researcher",
description: "Collect and summarize market, competitor, and trend signals.",
zhName: "调研助手",
zhDescription: "适合收集市场信息、竞品动态和趋势摘要。",
searchAliases: ["市场", "竞品", "调研", "行业", "研究", "趋势"]
}
];
export const CURATED_GENERIC_SKILL_IDS = new Set(CURATED_GENERIC_SKILLS.map((skill) => skill.id));
export function getCuratedGenericSkillDefinition(skillId: string): CuratedGenericSkillDefinition | undefined {
return CURATED_GENERIC_SKILLS.find((skill) => skill.id === skillId);
}
import { existsSync } from "node:fs";
import path from "node:path";
import type { SystemSummary } from "@qjclaw/shared-types";
function uniquePaths(paths: Array<string | undefined>): string[] {
return [...new Set(paths.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))];
}
export function resolveGenericSkillsRoot(systemSummary: SystemSummary): string {
const envRoot = process.env.QJCLAW_SKILL_CATALOG_ROOT?.trim();
const devRepoSkillsRoot = path.resolve(systemSummary.appPath, "..", "..", "skills");
const legacyRepoSkillsRoot = path.resolve(systemSummary.appPath, "..", "..", "q-skills", "skills");
const cwdSkillsRoot = path.resolve(process.cwd(), "skills");
const packagedSkillsRoot = systemSummary.isPackaged
? path.join(systemSummary.resourcesPath, "skills")
: undefined;
const candidates = uniquePaths([
envRoot,
packagedSkillsRoot,
devRepoSkillsRoot,
cwdSkillsRoot,
legacyRepoSkillsRoot
]);
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] ?? devRepoSkillsRoot;
}
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import type {
SkillCatalogAvailability,
SkillCatalogItem,
SkillCatalogSource,
SystemSummary,
WorkspaceSkillSummary
} from "@qjclaw/shared-types";
import {
CURATED_GENERIC_SKILLS,
CURATED_GENERIC_SKILL_IDS,
getCuratedGenericSkillDefinition
} from "./curated-skills.js";
import type { ProjectStoreService } from "./project-store.js";
interface SkillCatalogServiceOptions {
systemSummary: SystemSummary;
projectStore: ProjectStoreService;
qSkillsRoot?: string;
}
interface ParsedSkillDocument {
name?: string;
description?: string;
body: string;
}
const TOKEN_ZH_MAP: Record<string, string> = {
xhs: "小红书",
pipeline: "全流程",
workflow: "工作流",
writer: "写作",
search: "搜索",
doc: "文档",
docs: "文档",
file: "文件",
market: "市场",
researcher: "调研",
note: "笔记",
organizer: "整理",
email: "邮件",
pdf: "PDF",
pptx: "演示",
xlsx: "表格",
docx: "文档"
};
const SKILL_ZH_OVERRIDES: Record<string, { name: string; description: string; aliases?: string[] }> = {
xhs: {
name: "小红书内容助手",
description: "适合生成小红书文案、整理发布内容和处理相关对话。",
aliases: ["小红书", "笔记", "种草", "发帖", "发布"]
},
"xhs-pipeline": {
name: "小红书全流程助手",
description: "适合把选题、文案、配图和发布流程串起来执行。",
aliases: ["小红书", "全流程", "自动发布", "发帖", "工作流"]
},
xlsx: {
name: "表格助手",
description: "适合处理 Excel、表格计算、数据汇总和清单整理。"
},
docx: {
name: "文档助手",
description: "适合起草 Word 文档、报告、通知和正式材料。"
},
pdf: {
name: "PDF 助手",
description: "适合查看、提取、合并和整理 PDF 文件内容。"
},
pptx: {
name: "演示助手",
description: "适合整理 PPT、大纲、汇报页和演示材料。"
},
"file-skill": {
name: "文件整理助手",
description: "适合做文件分类、批量整理、归档和目录收纳。"
},
"note-organizer": {
name: "笔记整理助手",
description: "适合梳理会议纪要、学习笔记、标签分类和内容归并。"
},
"email-skill": {
name: "邮件助手",
description: "适合写邮件、查邮件、整理收件箱和邮件沟通。"
},
"market-researcher": {
name: "调研助手",
description: "适合收集市场信息、竞品动态和趋势摘要。"
}
};
function normalizeSearch(value: string): string {
return value
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function cleanCopy(value: string | undefined): string {
return (value ?? "")
.replace(/\r/g, "")
.replace(/[`*_>#-]+/g, " ")
.replace(/\[(.*?)\]\((.*?)\)/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
function readScalar(lines: string[], startIndex: number): { value?: string; nextIndex: number } {
const currentLine = lines[startIndex] ?? "";
const [, rawValue = ""] = currentLine.split(/:\s*/, 2);
const directValue = rawValue.trim();
if (!directValue || directValue === "|" || directValue === ">") {
const block: string[] = [];
let cursor = startIndex + 1;
while (cursor < lines.length) {
const nextLine = lines[cursor];
if (!nextLine.startsWith(" ") && nextLine.trim()) {
break;
}
block.push(nextLine.replace(/^ /, ""));
cursor += 1;
}
return {
value: cleanCopy(block.join(" ").trim()),
nextIndex: cursor - 1
};
}
return {
value: cleanCopy(directValue.replace(/^['"]|['"]$/g, "")),
nextIndex: startIndex
};
}
function parseSkillDocument(content: string): ParsedSkillDocument {
const normalized = content.replace(/^\uFEFF/, "");
const match = normalized.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?/);
if (!match) {
return { body: normalized };
}
const lines = match[1].split(/\r?\n/);
let name: string | undefined;
let description: string | undefined;
for (let index = 0; index < lines.length; index += 1) {
const trimmed = lines[index].trimStart();
if (trimmed.startsWith("name:")) {
const parsed = readScalar(lines, index);
name = parsed.value;
index = parsed.nextIndex;
continue;
}
if (trimmed.startsWith("description:")) {
const parsed = readScalar(lines, index);
description = parsed.value;
index = parsed.nextIndex;
}
}
return {
name,
description,
body: normalized.slice(match[0].length)
};
}
function extractBodySummary(body: string): string {
const lines = body
.split(/\r?\n/)
.map((line) => cleanCopy(line))
.filter(Boolean);
return lines.find((line) => !line.startsWith("##") && !line.startsWith("#")) ?? lines[0] ?? "";
}
function translateSkillId(skillId: string): string {
const override = SKILL_ZH_OVERRIDES[skillId];
if (override) {
return override.name;
}
return skillId
.split("-")
.filter(Boolean)
.map((part) => TOKEN_ZH_MAP[part] ?? part.toUpperCase())
.join("");
}
function summarizeSkill(skillId: string, description: string): string {
const override = SKILL_ZH_OVERRIDES[skillId];
if (override) {
return override.description;
}
const cleaned = cleanCopy(description);
if (!cleaned) {
return `适合处理${translateSkillId(skillId)}相关任务。`;
}
const firstSentence = cleaned.split(/[。!?!?]/)[0]?.trim() ?? cleaned;
return firstSentence.length > 42 ? `${firstSentence.slice(0, 42)}…` : firstSentence;
}
function buildSelectionHint(selectable: boolean, isProjectSkill: boolean): string | undefined {
if (selectable) {
return isProjectSkill ? "当前项目已接入,首页输入框里点 @ 就能直接调用。" : "当前已经接入,可以直接在首页对话里调用。";
}
return "当前先作为能力说明展示,暂时不能直接调用。";
}
function compareCatalogItems(left: SkillCatalogItem, right: SkillCatalogItem): number {
const priority = (item: SkillCatalogItem) => {
if (item.selectable && item.isProjectSkill) {
return 0;
}
if (item.selectable) {
return 1;
}
return 2;
};
return priority(left) - priority(right) || left.zhName.localeCompare(right.zhName, "zh-CN");
}
export class SkillCatalogService {
private readonly projectStore: ProjectStoreService;
private readonly qSkillsRoot: string;
constructor(options: SkillCatalogServiceOptions) {
this.projectStore = options.projectStore;
this.qSkillsRoot = options.qSkillsRoot
?? process.env.QJCLAW_SKILL_CATALOG_ROOT?.trim()
?? path.resolve(options.systemSummary.appPath, "..", "..", "q-skills", "skills");
}
async listForActiveProject(): Promise<SkillCatalogItem[]> {
const [repoItems, projectItems] = await Promise.all([
this.listCuratedRepoSkills(),
this.listActiveProjectSkills()
]);
const merged = new Map<string, SkillCatalogItem>();
for (const item of repoItems) {
merged.set(item.id, item);
}
for (const item of projectItems) {
const current = merged.get(item.id);
if (!current) {
merged.set(item.id, item);
continue;
}
const source: SkillCatalogSource = current.source === "project" ? "project" : "hybrid";
merged.set(item.id, {
...current,
name: item.name || current.name,
zhName: item.zhName || current.zhName,
description: item.description || current.description,
zhDescription: item.zhDescription || current.zhDescription,
source,
selectable: true,
isProjectSkill: item.isProjectSkill,
showInSkillsPage: current.showInSkillsPage || item.showInSkillsPage,
selectionHint: buildSelectionHint(true, item.isProjectSkill),
searchText: normalizeSearch([current.searchText, item.searchText].join(" "))
});
}
return [...merged.values()].sort(compareCatalogItems);
}
private async listCuratedRepoSkills(): Promise<SkillCatalogItem[]> {
const idsInRoot = new Set(
(await readdir(this.qSkillsRoot, { withFileTypes: true }).catch(() => []))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
);
const items: SkillCatalogItem[] = [];
for (const skill of CURATED_GENERIC_SKILLS) {
if (!idsInRoot.has(skill.id)) {
continue;
}
const parsed = await this.readSkillCatalogDocument(skill.id);
const description = skill.description || parsed.description || extractBodySummary(parsed.body) || skill.zhDescription;
items.push({
id: skill.id,
name: parsed.name || skill.id,
zhName: skill.zhName,
description,
zhDescription: skill.zhDescription,
category: "通用办公技能",
source: "q-skills",
availability: "usable",
selectable: true,
isProjectSkill: false,
showInSkillsPage: true,
searchText: normalizeSearch([
skill.id,
parsed.name || "",
skill.zhName,
skill.zhDescription,
description,
...skill.searchAliases
].join(" ")),
selectionHint: buildSelectionHint(true, false)
});
}
return items;
}
private async listActiveProjectSkills(): Promise<SkillCatalogItem[]> {
const projects = await this.projectStore.listProjects().catch(() => []);
if (!projects.length) {
return [];
}
const activeProject = await this.projectStore.getActiveProject().catch(() => null);
if (!activeProject) {
return [];
}
const projectSkills = (await this.projectStore.listProjectSkills(activeProject.id)).filter((skill) => skill.ready);
const items = await Promise.all(projectSkills.map(async (skill) => this.toProjectCatalogItem(skill)));
return items.filter((item): item is SkillCatalogItem => Boolean(item));
}
private async toProjectCatalogItem(skill: WorkspaceSkillSummary): Promise<SkillCatalogItem | null> {
const target = await this.projectStore.getCurrentProjectSkillTarget(skill.id).catch(() => undefined);
const raw = target?.localPath ? await readFile(target.localPath, "utf8").catch(() => "") : "";
const parsed = parseSkillDocument(raw);
const curated = getCuratedGenericSkillDefinition(skill.id);
const description = curated?.description || parsed.description || extractBodySummary(parsed.body) || skill.description;
const override = SKILL_ZH_OVERRIDES[skill.id];
const zhName = override?.name || curated?.zhName || translateSkillId(skill.id);
const zhDescription = override?.description || curated?.zhDescription || summarizeSkill(skill.id, description);
const source: SkillCatalogSource = CURATED_GENERIC_SKILL_IDS.has(skill.id)
? "hybrid"
: skill.category === "project"
? "project"
: "hybrid";
return {
id: skill.id,
name: parsed.name || skill.name,
zhName,
description,
zhDescription,
category: skill.category === "project" ? "项目技能" : "通用办公技能",
source,
availability: "usable" as SkillCatalogAvailability,
selectable: true,
isProjectSkill: skill.category === "project",
showInSkillsPage: CURATED_GENERIC_SKILL_IDS.has(skill.id),
searchText: normalizeSearch([
skill.id,
skill.name,
parsed.name || "",
zhName,
zhDescription,
description,
...(override?.aliases ?? []),
...(curated?.searchAliases ?? [])
].join(" ")),
selectionHint: buildSelectionHint(true, skill.category === "project")
};
}
private async readSkillCatalogDocument(skillId: string): Promise<ParsedSkillDocument> {
const skillRoot = path.join(this.qSkillsRoot, skillId);
const skillDocPath = path.join(skillRoot, "SKILL.md");
const raw = await readFile(skillDocPath, "utf8").catch(() => "");
return parseSkillDocument(raw);
}
}
import type { SkillCatalogItem } from "@qjclaw/shared-types";
export interface SkillTriggerMatch {
query: string;
start: number;
end: number;
}
export function normalizeSkillSearch(value: string): string {
return value
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
export function findSkillTriggerMatch(value: string, cursor: number): SkillTriggerMatch | null {
const normalizedCursor = Math.max(0, Math.min(cursor, value.length));
const prefix = value.slice(0, normalizedCursor);
const match = prefix.match(/(?:^|[\s((【\[{'""'])@([^\s@]*)$/u);
if (!match) {
return null;
}
const raw = match[0];
const atOffset = raw.lastIndexOf("@");
if (atOffset < 0) {
return null;
}
return {
query: match[1] ?? "",
start: prefix.length - raw.length + atOffset,
end: normalizedCursor
};
}
export function stripSkillTriggerFromPrompt(value: string, trigger: SkillTriggerMatch | null): string {
if (!trigger) {
return value;
}
const next = `${value.slice(0, trigger.start)}${value.slice(trigger.end)}`;
return next.replace(/[ \t]{2,}/g, " ").replace(/\n{3,}/g, "\n\n");
}
export interface ResolvePromptSkillResult {
cleanPrompt: string;
resolvedSkillId: string | undefined;
error?: string;
}
function findSkillTriggerInFullText(value: string): SkillTriggerMatch | null {
const match = value.match(/(?:^|[\s((【\[{'""'])@([^\s@]+)/u);
if (!match || !match[1]) {
return null;
}
const raw = match[0];
const atOffset = raw.lastIndexOf("@");
const start = (match.index ?? 0) + atOffset;
return {
query: match[1],
start,
end: start + 1 + match[1].length
};
}
export function resolvePromptSkill(
promptText: string,
catalog: SkillCatalogItem[]
): ResolvePromptSkillResult {
const trigger = findSkillTriggerInFullText(promptText);
if (!trigger || !trigger.query) {
return { cleanPrompt: promptText, resolvedSkillId: undefined };
}
const q = trigger.query.toLowerCase();
const selectables = catalog.filter((s) => s.selectable);
const matches = selectables.filter(
(s) =>
s.zhName.toLowerCase() === q ||
s.name.toLowerCase() === q ||
normalizeSkillSearch(s.searchText).split(/\s+/).includes(q)
);
if (matches.length === 0) {
return {
cleanPrompt: promptText,
resolvedSkillId: undefined,
error: `未找到可用技能「${trigger.query}」,请从弹层中选择`
};
}
if (matches.length > 1) {
return {
cleanPrompt: promptText,
resolvedSkillId: undefined,
error: `「${trigger.query}」匹配到多个技能,请从弹层中明确选择`
};
}
return {
cleanPrompt: stripSkillTriggerFromPrompt(promptText, trigger),
resolvedSkillId: matches[0]!.id
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment