From 0015f75e306911dc95f65d55a1aa63a8557fc806 Mon Sep 17 00:00:00 2001 From: MounteZ22 <1756804740@qq.com> Date: Sat, 4 Apr 2026 14:06:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E6=96=B0=E5=AE=89=E8=A3=85=E7=9A=84=20Skill=20?= =?UTF-8?q?=E5=88=B0=E5=85=A8=E5=B1=80=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/electron/package.json | 6 +- apps/electron/src/main/ipc.ts | 18 ++ .../src/main/lib/agent-prompt-builder.ts | 8 + .../src/main/lib/agent-workspace-manager.ts | 161 +++++++++++++++- apps/electron/src/main/lib/config-paths.ts | 25 ++- .../src/main/lib/workspace-watcher.ts | 50 ++++- apps/electron/src/preload/index.ts | 14 ++ .../components/settings/AgentSettings.tsx | 173 +++++++++++++++++- packages/shared/package.json | 2 +- packages/shared/src/types/agent.ts | 7 + 10 files changed, 442 insertions(+), 22 deletions(-) diff --git a/apps/electron/package.json b/apps/electron/package.json index cf959613..b40d5b24 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "@proma/electron", - "version": "0.8.1", + "version": "0.8.3", "description": "Proma next gen ai software with general agents - Electron App", "main": "dist/main.cjs", "author": { @@ -13,8 +13,8 @@ "dev": "concurrently -k -n vite,electron -c blue,green \"bun run dev:vite\" \"bun run dev:electron\"", "dev:split": "bash scripts/dev-split.sh", "dev:vite": "vite dev", - "dev:kill": "pkill -f 'electronmon \\.' 2>/dev/null; pkill -f 'electron.*dist/main' 2>/dev/null; sleep 0.5; true", - "dev:electron": "bun run dev:kill && bun run build:main && bun run build:preload && bun run build:resources && sleep 2 && concurrently -k -n main,preload,app -c yellow,magenta,cyan \"bun run watch:main\" \"bun run watch:preload\" \"bunx electronmon .\"", + "dev:kill": "pkill -f 'electronmon \\.' 2>/dev/null; pkill -f 'electron.*dist/main' 2>/dev/null; node -e \"setTimeout(() => process.exit(0), 500)\"; true", + "dev:electron": "bun run dev:kill && bun run build:main && bun run build:preload && bun run build:resources && node -e \"setTimeout(() => process.exit(0), 2000)\" && concurrently -k -n main,preload,app -c yellow,magenta,cyan \"bun run watch:main\" \"bun run watch:preload\" \"bunx electronmon .\"", "build:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk", "build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload.cjs --external:electron", "watch:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --watch=forever", diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index f9c6a3ec..a0a59e29 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -148,9 +148,11 @@ import { getWorkspaceMcpConfig, saveWorkspaceMcpConfig, getAllWorkspaceSkills, + getGlobalSkills, getWorkspaceCapabilities, getAgentWorkspace, deleteWorkspaceSkill, + installGlobalSkill, toggleWorkspaceSkill, getWorkspacePermissionMode, setWorkspacePermissionMode, @@ -896,6 +898,14 @@ export function registerIpcHandlers(): void { } ) + // 获取全局 Skill 列表(~/.proma/default-skills/) + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_GLOBAL_SKILLS, + async (): Promise => { + return getGlobalSkills() + } + ) + // 获取工作区 Skills 目录绝对路径 ipcMain.handle( AGENT_IPC_CHANNELS.GET_SKILLS_DIR, @@ -920,6 +930,14 @@ export function registerIpcHandlers(): void { } ) + // 从全局 Skill 安装到工作区 + ipcMain.handle( + AGENT_IPC_CHANNELS.INSTALL_GLOBAL_SKILL, + async (_, workspaceSlug: string, skillSlug: string): Promise => { + return installGlobalSkill(workspaceSlug, skillSlug) + } + ) + // 发送 Agent 消息(触发 Agent SDK 流式响应) ipcMain.handle( AGENT_IPC_CHANNELS.SEND_MESSAGE, diff --git a/apps/electron/src/main/lib/agent-prompt-builder.ts b/apps/electron/src/main/lib/agent-prompt-builder.ts index f4055070..d8eb13c9 100644 --- a/apps/electron/src/main/lib/agent-prompt-builder.ts +++ b/apps/electron/src/main/lib/agent-prompt-builder.ts @@ -196,6 +196,14 @@ Agent 工具支持 \`model\` 参数(可选值:\`sonnet\` / \`opus\` / \`haik - 当前会话目录(cwd): ~/.proma/agent-workspaces/${ctx.workspaceSlug}/${ctx.sessionId}/ - MCP 配置: ~/.proma/agent-workspaces/${ctx.workspaceSlug}/mcp.json(顶层 key 是 \`servers\`) - Skills 目录: ~/.proma/agent-workspaces/${ctx.workspaceSlug}/skills/ +- 全局 Skill 仓库: ~/.proma/default-skills/ + +### Skill 安装约定 + +- 在当前工作区里新建的 Skill 会立刻可用 +- Proma 会自动把当前工作区中新建的 Skill 同步到全局 Skill 仓库 +- 从全局仓库复制到工作区的 Skill 默认视为局部副本,不应自动反向覆盖全局 +- 如果要安装一个全新的 Skill,优先让它先落到当前工作区,再由系统自动同步到全局 ### .context 目录层级 diff --git a/apps/electron/src/main/lib/agent-workspace-manager.ts b/apps/electron/src/main/lib/agent-workspace-manager.ts index 749e512e..9d285739 100644 --- a/apps/electron/src/main/lib/agent-workspace-manager.ts +++ b/apps/electron/src/main/lib/agent-workspace-manager.ts @@ -19,6 +19,7 @@ import { getInactiveSkillsDir, getDefaultSkillsDir, parseSkillVersion, + SKILL_STORAGE_META_FILE, } from './config-paths' import type { AgentWorkspace, McpServerEntry, WorkspaceMcpConfig, SkillMeta, WorkspaceCapabilities, PromaPermissionMode } from '@proma/shared' import { migratePermissionMode } from '@proma/shared' @@ -36,6 +37,48 @@ interface AgentWorkspacesIndex { /** 当前索引版本 */ const INDEX_VERSION = 2 +interface SkillStorageMeta { + version: 1 + sourceType: 'bundled' | 'workspace-authored' + sourceWorkspaceSlug?: string + syncedAt: number +} + +function readSkillStorageMeta(skillDir: string): SkillStorageMeta | null { + const metaPath = join(skillDir, SKILL_STORAGE_META_FILE) + if (!existsSync(metaPath)) return null + + try { + const raw = readFileSync(metaPath, 'utf-8') + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== 1) return null + if (parsed.sourceType !== 'bundled' && parsed.sourceType !== 'workspace-authored') return null + + return { + version: 1, + sourceType: parsed.sourceType, + sourceWorkspaceSlug: parsed.sourceWorkspaceSlug, + syncedAt: typeof parsed.syncedAt === 'number' ? parsed.syncedAt : Date.now(), + } + } catch (error) { + console.warn('[Agent 工作区] 读取 Skill 元数据失败:', skillDir, error) + return null + } +} + +function writeSkillStorageMeta(skillDir: string, meta: SkillStorageMeta): void { + writeFileSync(join(skillDir, SKILL_STORAGE_META_FILE), JSON.stringify(meta, null, 2), 'utf-8') +} + +function createWorkspaceAuthoredMeta(workspaceSlug: string): SkillStorageMeta { + return { + version: 1, + sourceType: 'workspace-authored', + sourceWorkspaceSlug: workspaceSlug, + syncedAt: Date.now(), + } +} + /** * 读取工作区索引文件 * @@ -461,11 +504,115 @@ export function getWorkspaceSkills(workspaceSlug: string): SkillMeta[] { return scanSkillsInDir(getWorkspaceSkillsDir(workspaceSlug), true) } +export function getGlobalSkills(): SkillMeta[] { + return scanSkillsInDir(getDefaultSkillsDir(), true, true) +} + +export function installGlobalSkill(workspaceSlug: string, skillSlug: string): SkillMeta { + const sourcePath = join(getDefaultSkillsDir(), skillSlug) + const activeTargetPath = join(getWorkspaceSkillsDir(workspaceSlug), skillSlug) + const inactiveTargetPath = join(getInactiveSkillsDir(workspaceSlug), skillSlug) + + if (!existsSync(sourcePath)) { + throw new Error(`全局 Skill 不存在: ${skillSlug}`) + } + + if (existsSync(activeTargetPath) || existsSync(inactiveTargetPath)) { + throw new Error(`Skill 已存在于当前工作区: ${skillSlug}`) + } + + cpSync(sourcePath, activeTargetPath, { recursive: true }) + console.log(`[Agent 工作区] 已安装全局 Skill: ${workspaceSlug}/${skillSlug}`) + + const skillMdPath = join(activeTargetPath, 'SKILL.md') + const content = readFileSync(skillMdPath, 'utf-8') + return parseSkillFrontmatter(content, skillSlug, true, false, readSkillStorageMeta(activeTargetPath)) +} + +/** + * 将当前工作区中新创建的 Skill 同步到全局仓库 + * + * 规则: + * - 当前工作区新建的 Skill:自动写入 ~/.proma/default-skills/ + * - 从全局复制到工作区的 Skill:不自动反向覆盖全局 + * - 已有其他工作区作为来源的全局 Skill:不跨工作区抢占写入权 + */ +export function syncWorkspaceSkillToGlobal(workspaceSlug: string, skillSlug: string): void { + const workspaceSkillPath = join(getWorkspaceSkillsDir(workspaceSlug), skillSlug) + const workspaceSkillMdPath = join(workspaceSkillPath, 'SKILL.md') + + if (!existsSync(workspaceSkillPath) || !existsSync(workspaceSkillMdPath)) { + return + } + + const workspaceMeta = readSkillStorageMeta(workspaceSkillPath) + + // 从全局复制到工作区的 Skill 默认视为局部副本,不自动反向覆盖全局 + if (workspaceMeta?.sourceType === 'bundled') { + return + } + + if ( + workspaceMeta?.sourceType === 'workspace-authored' && + workspaceMeta.sourceWorkspaceSlug && + workspaceMeta.sourceWorkspaceSlug !== workspaceSlug + ) { + return + } + + const globalSkillPath = join(getDefaultSkillsDir(), skillSlug) + const globalMeta = readSkillStorageMeta(globalSkillPath) + + if (existsSync(globalSkillPath) && globalMeta?.sourceType === 'bundled') { + console.log(`[Agent 工作区] 跳过同步到全局仓库(内置 Skill 同名): ${workspaceSlug}/${skillSlug}`) + return + } + + if (existsSync(globalSkillPath) && !globalMeta && !workspaceMeta) { + console.log(`[Agent 工作区] 跳过同步到全局仓库(全局 Skill 来源未知): ${workspaceSlug}/${skillSlug}`) + return + } + + if ( + globalMeta?.sourceType === 'workspace-authored' && + globalMeta.sourceWorkspaceSlug && + globalMeta.sourceWorkspaceSlug !== workspaceSlug + ) { + console.log(`[Agent 工作区] 跳过同步到全局仓库(由其他工作区维护): ${workspaceSlug}/${skillSlug}`) + return + } + + if (existsSync(globalSkillPath)) { + rmSync(globalSkillPath, { recursive: true, force: true }) + } + + cpSync(workspaceSkillPath, globalSkillPath, { recursive: true, force: true }) + + const syncMeta = createWorkspaceAuthoredMeta(workspaceSlug) + writeSkillStorageMeta(globalSkillPath, syncMeta) + writeSkillStorageMeta(workspaceSkillPath, syncMeta) + + console.log(`[Agent 工作区] 已同步 Skill 到全局仓库: ${workspaceSlug}/${skillSlug}`) +} + /** * 解析 SKILL.md 的 YAML frontmatter */ -function parseSkillFrontmatter(content: string, slug: string, enabled: boolean): SkillMeta { - const meta: SkillMeta = { slug, name: slug, enabled } +function parseSkillFrontmatter( + content: string, + slug: string, + enabled: boolean, + isGlobal = false, + storageMeta: SkillStorageMeta | null = null, +): SkillMeta { + const meta: SkillMeta = { + slug, + name: slug, + enabled, + ...(isGlobal ? { isGlobal: true } : {}), + ...(storageMeta?.sourceType ? { sourceType: storageMeta.sourceType } : {}), + ...(storageMeta?.sourceWorkspaceSlug ? { sourceWorkspaceSlug: storageMeta.sourceWorkspaceSlug } : {}), + } const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/) if (!fmMatch) return meta @@ -529,7 +676,7 @@ export function deleteWorkspaceSkill(workspaceSlug: string, skillSlug: string): * * 通用扫描逻辑,供 getWorkspaceSkills 和 getAllWorkspaceSkills 复用。 */ -function scanSkillsInDir(dir: string, enabled: boolean): SkillMeta[] { +function scanSkillsInDir(dir: string, enabled: boolean, isGlobal = false): SkillMeta[] { const skills: SkillMeta[] = [] try { @@ -544,7 +691,13 @@ function scanSkillsInDir(dir: string, enabled: boolean): SkillMeta[] { try { const content = readFileSync(skillMdPath, 'utf-8') - const meta = parseSkillFrontmatter(content, entry.name, enabled) + const meta = parseSkillFrontmatter( + content, + entry.name, + enabled, + isGlobal, + readSkillStorageMeta(join(dir, entry.name)), + ) skills.push(meta) } catch { console.warn(`[Agent 工作区] 解析 Skill 失败: ${entry.name}`) diff --git a/apps/electron/src/main/lib/config-paths.ts b/apps/electron/src/main/lib/config-paths.ts index ee1111b4..2a32292f 100644 --- a/apps/electron/src/main/lib/config-paths.ts +++ b/apps/electron/src/main/lib/config-paths.ts @@ -6,11 +6,19 @@ */ import { join } from 'node:path' -import { mkdirSync, existsSync, cpSync, readdirSync, readFileSync } from 'node:fs' +import { mkdirSync, existsSync, cpSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' /** 配置目录名称 */ const CONFIG_DIR_NAME = '.proma' +export const SKILL_STORAGE_META_FILE = '.proma-skill.json' + +interface SkillStorageMeta { + version: 1 + sourceType: 'bundled' | 'workspace-authored' + sourceWorkspaceSlug?: string + syncedAt: number +} /** * 获取配置目录路径 @@ -383,6 +391,16 @@ function compareSemver(a: string, b: string): number { return 0 } +function writeBundledSkillMeta(skillDir: string): void { + const meta: SkillStorageMeta = { + version: 1, + sourceType: 'bundled', + syncedAt: Date.now(), + } + + writeFileSync(join(skillDir, SKILL_STORAGE_META_FILE), JSON.stringify(meta, null, 2), 'utf-8') +} + /** * 从 app bundle 同步默认 Skills 到 ~/.proma/default-skills/ * @@ -417,6 +435,7 @@ export function seedDefaultSkills(): void { if (!existsSync(target)) { // 缺失的 Skill:直接复制 cpSync(source, target, { recursive: true }) + writeBundledSkillMeta(target) console.log(`[配置] 已同步默认 Skill: ${entry.name}`) } else { // 已存在:比较版本,bundled 更新时覆盖 @@ -425,7 +444,11 @@ export function seedDefaultSkills(): void { if (compareSemver(bundledVer, existingVer) > 0) { cpSync(source, target, { recursive: true, force: true }) + writeBundledSkillMeta(target) console.log(`[配置] 已升级默认 Skill: ${entry.name} (${existingVer} → ${bundledVer})`) + } else { + // 历史版本可能没有元数据,这里顺手补齐 + writeBundledSkillMeta(target) } } } diff --git a/apps/electron/src/main/lib/workspace-watcher.ts b/apps/electron/src/main/lib/workspace-watcher.ts index f986af90..504856e4 100644 --- a/apps/electron/src/main/lib/workspace-watcher.ts +++ b/apps/electron/src/main/lib/workspace-watcher.ts @@ -16,11 +16,13 @@ import type { FSWatcher } from 'node:fs' import type { BrowserWindow } from 'electron' import { AGENT_IPC_CHANNELS } from '@proma/shared' import { getAgentWorkspacesDir } from './config-paths' +import { syncWorkspaceSkillToGlobal } from './agent-workspace-manager' /** debounce 延迟(ms) */ const DEBOUNCE_MS = 300 let watcher: FSWatcher | null = null +const changedWorkspaceSkills = new Set() /** 附加目录监听器:路径 → FSWatcher */ const attachedWatchers = new Map() @@ -52,16 +54,24 @@ export function startWorkspaceWatcher(win: BrowserWindow): void { if (!filename || win.isDestroyed()) return // filename 格式: {slug}/mcp.json 或 {slug}/skills/xxx/SKILL.md 或 {slug}/{sessionId}/file.txt + const normalizedFilename = filename.replace(/\\/g, '/') const isCapabilitiesChange = - filename.endsWith('/mcp.json') || - filename.endsWith('\\mcp.json') || - filename.includes('/skills/') || - filename.includes('\\skills/') + normalizedFilename.endsWith('/mcp.json') || + normalizedFilename.includes('/skills/') || + normalizedFilename.includes('/skills-inactive/') + + if (normalizedFilename.includes('/skills/')) { + const skillRef = extractWorkspaceSkillRef(normalizedFilename) + if (skillRef) { + changedWorkspaceSkills.add(skillRef) + } + } if (isCapabilitiesChange) { // MCP/Skills 变化 → 通知侧边栏刷新 if (capabilitiesTimer) clearTimeout(capabilitiesTimer) capabilitiesTimer = setTimeout(() => { + flushWorkspaceSkillSyncQueue() if (!win.isDestroyed()) { win.webContents.send(AGENT_IPC_CHANNELS.CAPABILITIES_CHANGED) } @@ -94,6 +104,7 @@ export function stopWorkspaceWatcher(): void { watcher = null console.log('[工作区监听] 已停止') } + changedWorkspaceSkills.clear() // 同时清理所有附加目录监听器 for (const [dirPath, w] of attachedWatchers) { w.close() @@ -146,3 +157,34 @@ export function unwatchAttachedDirectory(dirPath: string): void { console.log('[附加目录监听] 已停止:', dirPath) } } + +function extractWorkspaceSkillRef(filename: string): string | null { + const segments = filename.split('/').filter(Boolean) + if (segments.length < 3) return null + + const skillsIndex = segments.indexOf('skills') + if (skillsIndex !== 1) return null + + const workspaceSlug = segments[0] + const skillSlug = segments[2] + + if (!workspaceSlug || !skillSlug) return null + return `${workspaceSlug}:${skillSlug}` +} + +function flushWorkspaceSkillSyncQueue(): void { + if (changedWorkspaceSkills.size === 0) return + + for (const ref of changedWorkspaceSkills) { + const [workspaceSlug, skillSlug] = ref.split(':') + if (!workspaceSlug || !skillSlug) continue + + try { + syncWorkspaceSkillToGlobal(workspaceSlug, skillSlug) + } catch (error) { + console.error('[工作区监听] 同步 Skill 到全局仓库失败:', ref, error) + } + } + + changedWorkspaceSkills.clear() +} diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 194cf1f3..36312147 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -379,6 +379,9 @@ export interface ElectronAPI { /** 获取工作区 Skill 列表(含活跃和不活跃) */ getWorkspaceSkills: (workspaceSlug: string) => Promise + /** 获取全局 Skill 列表(~/.proma/default-skills/) */ + getGlobalSkills: () => Promise + /** 获取工作区 Skills 目录绝对路径 */ getWorkspaceSkillsDir: (workspaceSlug: string) => Promise @@ -388,6 +391,9 @@ export interface ElectronAPI { /** 切换工作区 Skill 启用/禁用 */ toggleWorkspaceSkill: (workspaceSlug: string, skillSlug: string, enabled: boolean) => Promise + /** 从全局 Skill 安装到工作区 */ + installGlobalSkill: (workspaceSlug: string, skillSlug: string) => Promise + /** 订阅 Agent 流式事件(返回清理函数) */ onAgentStreamEvent: (callback: (event: AgentStreamEvent) => void) => () => void @@ -1060,6 +1066,10 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.GET_SKILLS, workspaceSlug) }, + getGlobalSkills: () => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.GET_GLOBAL_SKILLS) + }, + getWorkspaceSkillsDir: (workspaceSlug: string) => { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.GET_SKILLS_DIR, workspaceSlug) }, @@ -1072,6 +1082,10 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.TOGGLE_SKILL, workspaceSlug, skillSlug, enabled) }, + installGlobalSkill: (workspaceSlug: string, skillSlug: string) => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.INSTALL_GLOBAL_SKILL, workspaceSlug, skillSlug) + }, + onAgentStreamEvent: (callback: (event: AgentStreamEvent) => void) => { const listener = (_: unknown, event: AgentStreamEvent): void => callback(event) ipcRenderer.on(AGENT_IPC_CHANNELS.STREAM_EVENT, listener) diff --git a/apps/electron/src/renderer/components/settings/AgentSettings.tsx b/apps/electron/src/renderer/components/settings/AgentSettings.tsx index 997aab13..48841507 100644 --- a/apps/electron/src/renderer/components/settings/AgentSettings.tsx +++ b/apps/electron/src/renderer/components/settings/AgentSettings.tsx @@ -10,9 +10,11 @@ import * as React from 'react' import { useAtomValue, useSetAtom } from 'jotai' +import { toast } from 'sonner' import { Plus, Plug, Pencil, Trash2, Sparkles, FolderOpen, MessageSquare, ShieldCheck, ChevronDown, ChevronRight, Brain, ImagePlus, Settings } from 'lucide-react' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { @@ -68,25 +70,33 @@ export function AgentSettings(): React.ReactElement { // MCP 配置 const [mcpConfig, setMcpConfig] = React.useState({ servers: {} }) const [skills, setSkills] = React.useState([]) + const [globalSkills, setGlobalSkills] = React.useState([]) const [skillsDir, setSkillsDir] = React.useState('') + const [showInstallDialog, setShowInstallDialog] = React.useState(false) + const [installingSkill, setInstallingSkill] = React.useState(null) const [loading, setLoading] = React.useState(true) /** 加载 MCP 配置和 Skills */ const loadData = React.useCallback(async () => { if (!workspaceSlug) { + setSkills([]) + setGlobalSkills([]) + setSkillsDir('') setLoading(false) return } try { - const [config, skillList, dir] = await Promise.all([ + const [config, skillList, dir, globalList] = await Promise.all([ window.electronAPI.getWorkspaceMcpConfig(workspaceSlug), window.electronAPI.getWorkspaceSkills(workspaceSlug), window.electronAPI.getWorkspaceSkillsDir(workspaceSlug), + window.electronAPI.getGlobalSkills(), ]) setMcpConfig(config) setSkills(skillList) setSkillsDir(dir) + setGlobalSkills(globalList) } catch (error) { console.error('[Agent 设置] 加载工作区配置失败:', error) } finally { @@ -151,15 +161,17 @@ mcp.json 格式如下: /** 构建 Skill 配置提示词 */ const buildSkillPrompt = (): string => { const skillsDir = `~/.proma/agent-workspaces/${workspaceSlug}/skills/` + const globalSkillsDir = '~/.proma/default-skills/' const skillList = skills.length > 0 ? skills.map((s) => `- ${s.name}: ${s.description ?? '无描述'}`).join('\n') : '暂无 Skill' - return `请帮我配置当前工作区的 Skills,你要主动来帮我实,现你可以采用联网搜索深度研究来尝试,当前环境已经有 Claude Agent SDK 了,除非不确定的时候才来问我,否则默认将帮我完成安装,而不是指导我。 + return `请帮我配置当前工作区的 Skills,你要主动来帮我实现,你可以采用联网搜索深度研究来尝试,当前环境已经有 Claude Agent SDK 了,除非不确定的时候才来问我,否则默认将帮我完成安装,而不是指导我。 ## 工作区信息 - 工作区: ${currentWorkspace.name} - Skills 目录: ${skillsDir} +- 全局 Skill 仓库: ${globalSkillsDir} ## Skill 格式 每个 Skill 是 skills/ 目录下的一个子目录,目录名即 slug。 @@ -177,7 +189,12 @@ Skill 的详细指令内容... ## 当前 Skills ${skillList} -请查看 skills/ 目录了解现有配置,根据我的需求创建或编辑 Skill。` +## 安装与同步规则 +- 如果是当前项目里还没有的新 Skill,请先安装到当前工作区的 skills/ 目录,确保我立刻能用 +- Proma 会把当前工作区中新建的 Skill 自动同步到全局 Skill 仓库 +- 如果是复用已有 Skill,请优先从全局 Skill 仓库安装到当前工作区,而不是重复造一个 + +请查看 skills/ 目录和全局 Skill 仓库,按照上面的规则创建、安装或编辑 Skill。` } /** 通过 Agent 对话完成配置 */ @@ -277,6 +294,26 @@ ${skillList} } } + /** 表单保存回调 */ + const handleInstallGlobalSkill = async (skillSlug: string): Promise => { + if (!workspaceSlug || installingSkill) return + + setInstallingSkill(skillSlug) + try { + const installed = await window.electronAPI.installGlobalSkill(workspaceSlug, skillSlug) + setSkills((prev) => prev.some((skill) => skill.slug === installed.slug) ? prev : [...prev, installed]) + bumpCapabilitiesVersion((v) => v + 1) + setShowInstallDialog(false) + toast.success(`已安装 Skill: ${installed.name}`) + } catch (error) { + console.error('[Agent 璁剧疆] 安装全局 Skill 失败:', error) + const message = error instanceof Error ? error.message : '未知错误' + toast.error('安装全局 Skill 失败', { description: message }) + } finally { + setInstallingSkill(null) + } + } + /** 表单保存回调 */ const handleFormSaved = (): void => { setViewMode('list') @@ -366,10 +403,16 @@ ${skillList} {/* 区块二:Skills(只读) */} - + description="当前工作区安装后会立即可用;在工作区中新建的 Skill 会自动同步到全局仓库" + action={ +
+ + {skillsDir ? ( + + 打开 Skills 目录 - - ) : undefined} + + ) : null} +
+ } > {loading ? (
加载中...
@@ -407,6 +452,15 @@ ${skillList} 跟 Proma Agent 对话完成配置
+ + ) } @@ -414,6 +468,107 @@ ${skillList} // ===== MCP 服务器行子组件 ===== /** 传输类型显示标签 */ +interface InstallGlobalSkillDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + globalSkills: SkillMeta[] + installedSkills: SkillMeta[] + installingSkill: string | null + onInstall: (skillSlug: string) => void +} + +function formatGlobalSkillSource(skill: SkillMeta): string { + if (skill.sourceType === 'bundled') return '内置' + if (skill.sourceType === 'workspace-authored') { + return skill.sourceWorkspaceSlug ? `来自 ${skill.sourceWorkspaceSlug}` : '工作区安装' + } + return '全局' +} + +function InstallGlobalSkillDialog({ + open, + onOpenChange, + globalSkills, + installedSkills, + installingSkill, + onInstall, +}: InstallGlobalSkillDialogProps): React.ReactElement { + const installedSlugs = React.useMemo( + () => new Set(installedSkills.map((skill) => skill.slug)), + [installedSkills] + ) + const availableSkills = React.useMemo( + () => globalSkills.filter((skill) => !installedSlugs.has(skill.slug)), + [globalSkills, installedSlugs] + ) + + return ( + + + + 从全局仓库安装 Skill + + 从 `~/.proma/default-skills/` 选择一个 Skill,复制到当前工作区的 `skills/` 目录。当前工作区中新建的 Skill 也会自动同步回这个全局仓库。 + + + +
+ {availableSkills.length === 0 ? ( + +
+ 当前没有可安装的全局 Skill,或者它们都已经安装到这个工作区了。 +
+
+ ) : ( +
+ {availableSkills.map((skill) => ( + +
+
+
+ +
+
+
+
{skill.name}
+ {skill.version ? ( + + v{skill.version} + + ) : null} + {skill.sourceType ? ( + + {formatGlobalSkillSource(skill)} + + ) : null} +
+
{skill.slug}
+
+
+ +
+ {skill.description ?? '暂无描述'} +
+ + +
+
+ ))} +
+ )} +
+
+
+ ) +} + const TRANSPORT_LABELS: Record = { stdio: 'stdio', http: 'HTTP', diff --git a/packages/shared/package.json b/packages/shared/package.json index 1c25557c..78f1a9e4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@proma/shared", - "version": "0.1.15", + "version": "0.1.17", "license": "Apache-2.0", "description": "Shared types, configs and utilities for proma", "type": "module", diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 60bdb60e..25ce03ed 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -660,6 +660,9 @@ export interface SkillMeta { description?: string icon?: string version?: string + isGlobal?: boolean + sourceType?: 'bundled' | 'workspace-authored' + sourceWorkspaceSlug?: string enabled: boolean } @@ -1142,12 +1145,16 @@ export const AGENT_IPC_CHANNELS = { TEST_MCP_SERVER: 'agent:test-mcp-server', /** 获取工作区 Skill 列表 */ GET_SKILLS: 'agent:get-skills', + /** 获取全局 Skill 列表(~/.proma/default-skills/) */ + GET_GLOBAL_SKILLS: 'agent:get-global-skills', /** 获取工作区 Skills 目录绝对路径 */ GET_SKILLS_DIR: 'agent:get-skills-dir', /** 删除工作区 Skill */ DELETE_SKILL: 'agent:delete-skill', /** 切换工作区 Skill 启用/禁用 */ TOGGLE_SKILL: 'agent:toggle-skill', + /** 从全局 Skill 安装到工作区 */ + INSTALL_GLOBAL_SKILL: 'agent:install-global-skill', // 流式事件(主进程 → 渲染进程推送) /** Agent 流式事件 */