diff --git a/README.md b/README.md index 38b9470..9a4bf5e 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,44 @@ macOS: macOS 打包态请直接指向 `.app` 内部的 `ciphertalk-mcp`,不要把 `CipherTalk.app` 本体当作 `command`。 +### AI Copilot Skill + +项目内置了 `ct-mcp-copilot` skill,用于让支持 Skills 的 Agent 更智能地使用 CipherTalk MCP: + +- 模糊联系人 / 会话查找 +- 线索补挖和候选比较 +- 导出前补问和请求校验 + +在应用内的 MCP 页面可以一键安装到本机支持的 Agent: + +- Codex:`~/.codex/skills` +- `.agents`:`~/.agents/skills` +- 以及主目录下发现的其他 `skills` 目录(如路径特征明显匹配 Agent) + +Skill 使用独立版本号,不跟应用版本绑定。页面会显示: + +- 当前内置 skill 版本 +- 本机已安装版本 +- 是否可更新(仅对比本地已安装版本是否落后) + +还支持直接导出本地 skill 压缩包: + +- 文件名格式:`ct-mcp-copilot-v.zip` +- 默认导出到系统 Downloads 目录 +- 可用于手动导入到支持 skills 的 Agent + +安装后可直接这样使用: + +- `使用 ct-mcp-copilot 帮我查这个人` +- `使用 ct-mcp-copilot 帮我补全导出问题` + +说明: + +- 安装器使用“复制安装”,不会创建软链接 +- 当前只管理内置 skill `ct-mcp-copilot` +- 当前只检查本地已安装版本是否落后,不检查远程最新版本 +- Cherry Studio 等 MCP 宿主继续使用 `mcpServers` 配置,不属于 skills 目录安装模型 + ### 参数示例 ```json diff --git a/electron/main.ts b/electron/main.ts index 489260c..477d2b3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -32,6 +32,7 @@ import { httpApiService } from './services/httpApiService' import { getBestCachePath, getRuntimePlatformInfo } from './services/platformService' import { getMcpLaunchConfig as getMcpLaunchConfigForUi, getMcpProxyConfig } from './services/mcp/runtime' import { mcpProxyService } from './services/mcp/proxyService' +import { skillInstallerService } from './services/skillInstallerService' type AppWithQuitFlag = typeof app & { isQuitting?: boolean @@ -1337,6 +1338,18 @@ function registerIpcHandlers() { return { success: true, deleted: result.deleted, nextActiveAccountId: result.nextActiveAccountId } }) + ipcMain.handle('skillInstaller:detectTargets', async (_, skillName: string) => { + return skillInstallerService.detectTargets(skillName) + }) + + ipcMain.handle('skillInstaller:installSkill', async (_, skillName: string) => { + return skillInstallerService.installSkill(skillName) + }) + + ipcMain.handle('skillInstaller:exportSkillZip', async (_, skillName: string) => { + return skillInstallerService.exportSkillZip(skillName) + }) + // HTTP API 管理 ipcMain.handle('httpApi:getStatus', async () => { return { success: true, status: httpApiService.getUiStatus() } diff --git a/electron/preload.ts b/electron/preload.ts index 1287c7a..86995a1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' import type { AccountProfile } from '../src/types/account' +import type { SkillInstallTarget } from './services/skillInstallerService' function getMcpLaunchConfigSafe(): Promise<{ command: string @@ -45,6 +46,14 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('accounts:delete', accountId, deleteLocalData) as Promise<{ success: boolean; error?: string; deleted?: AccountProfile | null; nextActiveAccountId?: string }> }, + skillInstaller: { + detectTargets: (skillName: string) => ipcRenderer.invoke('skillInstaller:detectTargets', skillName) as Promise, + installSkill: (skillName: string) => + ipcRenderer.invoke('skillInstaller:installSkill', skillName) as Promise<{ success: boolean; results: SkillInstallTarget[]; error?: string }>, + exportSkillZip: (skillName: string) => + ipcRenderer.invoke('skillInstaller:exportSkillZip', skillName) as Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }> + }, + // 数据库操作 db: { open: (dbPath: string, key?: string) => ipcRenderer.invoke('db:open', dbPath, key), diff --git a/electron/services/config.ts b/electron/services/config.ts index 3a928bb..0adce9c 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -317,7 +317,8 @@ export class ConfigService { const cachePath = String(profile.cachePath ?? fallback?.cachePath ?? '').trim() const imageXorKey = String(profile.imageXorKey ?? fallback?.imageXorKey ?? '').trim() const imageAesKey = String(profile.imageAesKey ?? fallback?.imageAesKey ?? '').trim() - const displayName = String(profile.displayName ?? fallback?.displayName ?? wxid || '未命名账号').trim() || wxid || '未命名账号' + const rawDisplayName = profile.displayName ?? fallback?.displayName ?? wxid ?? '' + const displayName = String(rawDisplayName).trim() || wxid || '未命名账号' return { wxid, diff --git a/electron/services/skillInstallerService.ts b/electron/services/skillInstallerService.ts new file mode 100644 index 0000000..b0fe5ba --- /dev/null +++ b/electron/services/skillInstallerService.ts @@ -0,0 +1,236 @@ +import { app } from 'electron' +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'fs' +import { homedir } from 'os' +import { dirname, join } from 'path' +import AdmZip from 'adm-zip' + +export type SupportedAgentKind = 'codex' | 'agents' + +export interface SkillInstallTarget { + agentKind: SupportedAgentKind + agentLabel: string + source: 'known' | 'discovered' + skillsDir: string + supported: boolean + installed: boolean + bundledVersion: string + installedVersion?: string + updateAvailable: boolean + installPath?: string + error?: string +} + +type SkillSource = { + name: string + relativePath: string +} + +type SkillMeta = { + name: string + version: string + description?: string +} + +const MANAGED_SKILLS: Record = { + 'ct-mcp-copilot': { + name: 'ct-mcp-copilot', + relativePath: join('sikll', 'ct-mcp-copilot') + } +} + +function getHomeDir() { + return homedir() || process.env.USERPROFILE || process.env.HOME || '' +} + +function getKnownAgentTargets(): Array<{ agentKind: SupportedAgentKind; agentLabel: string; skillsDir: string; source: 'known' }> { + const home = getHomeDir() + if (!home) return [] + return [ + { agentKind: 'codex', agentLabel: 'Codex', skillsDir: join(home, '.codex', 'skills'), source: 'known' }, + { agentKind: 'agents', agentLabel: '.agents', skillsDir: join(home, '.agents', 'skills'), source: 'known' } + ] +} + +function compareVersions(a: string, b: string): number { + const aParts = a.split('.').map((x) => Number.parseInt(x, 10) || 0) + const bParts = b.split('.').map((x) => Number.parseInt(x, 10) || 0) + const maxLen = Math.max(aParts.length, bParts.length) + for (let i = 0; i < maxLen; i += 1) { + const diff = (aParts[i] || 0) - (bParts[i] || 0) + if (diff !== 0) return diff + } + return 0 +} + +export class SkillInstallerService { + private readSkillMeta(skillDir: string): SkillMeta | null { + try { + const metaPath = join(skillDir, '.skill-meta.json') + if (!existsSync(metaPath)) return null + return JSON.parse(readFileSync(metaPath, 'utf8')) as SkillMeta + } catch { + return null + } + } + + private getSkillSourcePath(skillName: string): string | null { + const source = MANAGED_SKILLS[skillName] + if (!source) return null + return join(app.getAppPath(), source.relativePath) + } + + private getBundledVersion(skillName: string): string { + const sourcePath = this.getSkillSourcePath(skillName) + if (!sourcePath) return '0.0.0' + return this.readSkillMeta(sourcePath)?.version || '0.0.0' + } + + private collectDiscoveredSkillDirs(): Array<{ agentKind: SupportedAgentKind; agentLabel: string; skillsDir: string; source: 'discovered' }> { + const home = getHomeDir() + if (!home || !existsSync(home)) return [] + + const results: Array<{ agentKind: SupportedAgentKind; agentLabel: string; skillsDir: string; source: 'discovered' }> = [] + const seen = new Set() + const projectRoot = app.getAppPath().toLowerCase() + + const addIfMatch = (candidate: string) => { + const normalized = candidate.toLowerCase() + if (seen.has(normalized)) return + if (!existsSync(candidate)) return + if (!statSync(candidate).isDirectory()) return + if (!normalized.endsWith('\\skills') && !normalized.endsWith('/skills')) return + if (normalized.includes(projectRoot)) return + + const parentHint = dirname(candidate).toLowerCase() + if (!/(codex|agent|agents|claude)/.test(parentHint)) return + + seen.add(normalized) + results.push({ + agentKind: /codex/.test(parentHint) ? 'codex' : 'agents', + agentLabel: parentHint.includes('claude') ? '发现的 Claude/Agent Skills' : '发现的 Skills 目录', + skillsDir: candidate, + source: 'discovered' + }) + } + + try { + for (const entry of readdirSync(home, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('.')) continue + const levelOne = join(home, entry.name) + addIfMatch(join(levelOne, 'skills')) + + for (const nested of readdirSync(levelOne, { withFileTypes: true })) { + if (!nested.isDirectory()) continue + addIfMatch(join(levelOne, nested.name, 'skills')) + } + } + } catch { + // ignore scan errors + } + + return results + } + + detectTargets(skillName: string): SkillInstallTarget[] { + const sourcePath = this.getSkillSourcePath(skillName) + const hasSource = Boolean(sourcePath && existsSync(join(sourcePath, 'SKILL.md'))) + const bundledVersion = this.getBundledVersion(skillName) + + const mergedTargets = [...getKnownAgentTargets(), ...this.collectDiscoveredSkillDirs()] + .filter((target, index, arr) => arr.findIndex((item) => item.skillsDir.toLowerCase() === target.skillsDir.toLowerCase()) === index) + + return mergedTargets.map(({ agentKind, agentLabel, skillsDir, source }) => { + const installPath = join(skillsDir, skillName) + const installed = existsSync(join(installPath, 'SKILL.md')) + const installedVersion = installed ? (this.readSkillMeta(installPath)?.version || undefined) : undefined + + return { + agentKind, + agentLabel, + source, + skillsDir, + supported: hasSource && Boolean(getHomeDir()), + installed, + bundledVersion, + installedVersion, + updateAvailable: Boolean(installedVersion && compareVersions(installedVersion, bundledVersion) < 0), + installPath, + error: hasSource ? undefined : `Skill source not found for ${skillName}` + } + }) + } + + installSkill(skillName: string): { success: boolean; results: SkillInstallTarget[]; error?: string } { + const sourcePath = this.getSkillSourcePath(skillName) + if (!sourcePath || !existsSync(join(sourcePath, 'SKILL.md'))) { + return { + success: false, + error: `Skill source not found for ${skillName}`, + results: this.detectTargets(skillName) + } + } + + const results = this.detectTargets(skillName).map((target) => { + if (!target.supported || !target.installPath) { + return { + ...target, + installed: false, + error: target.error || 'Target is not supported on this device' + } + } + + try { + mkdirSync(dirname(target.installPath), { recursive: true }) + if (existsSync(target.installPath)) { + rmSync(target.installPath, { recursive: true, force: true }) + } + mkdirSync(target.skillsDir, { recursive: true }) + cpSync(sourcePath, target.installPath, { recursive: true, force: true }) + const installedMeta = this.readSkillMeta(target.installPath) + return { + ...target, + installed: true, + installedVersion: installedMeta?.version || target.bundledVersion, + updateAvailable: false, + error: undefined + } + } catch (error) { + return { + ...target, + installed: false, + error: String(error) + } + } + }) + + return { + success: results.some((item) => item.installed), + results, + error: results.every((item) => !item.installed) + ? results.map((item) => `${item.agentLabel}: ${item.error || 'install failed'}`).join(' | ') + : undefined + } + } + + exportSkillZip(skillName: string): { success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string } { + const sourcePath = this.getSkillSourcePath(skillName) + if (!sourcePath || !existsSync(join(sourcePath, 'SKILL.md'))) { + return { success: false, error: `Skill source not found for ${skillName}` } + } + + try { + const downloadsDir = app.getPath('downloads') + const version = this.getBundledVersion(skillName) + const fileName = `${skillName}-v${version}.zip` + const outputPath = join(downloadsDir, fileName) + const zip = new AdmZip() + zip.addLocalFolder(sourcePath, skillName) + zip.writeZip(outputPath) + return { success: true, outputPath, fileName, version } + } catch (error) { + return { success: false, error: String(error) } + } + } +} + +export const skillInstallerService = new SkillInstallerService() diff --git a/electron/services/wxKeyServiceMac.ts b/electron/services/wxKeyServiceMac.ts index 6d99c4a..2d0803d 100644 --- a/electron/services/wxKeyServiceMac.ts +++ b/electron/services/wxKeyServiceMac.ts @@ -995,7 +995,7 @@ export class WxKeyServiceMac { } const current = chunk.subarray(0, bytesRead) - const data = trailing ? Buffer.concat([trailing, current]) : current + const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current const key = this.searchAsciiKey(data, ciphertext) || this.searchUtf16Key(data, ciphertext) || this.searchAny16Key(data, ciphertext) if (key) return key trailing = data.subarray(Math.max(0, data.length - OVERLAP)) diff --git a/electron/types/adm-zip.d.ts b/electron/types/adm-zip.d.ts new file mode 100644 index 0000000..d1ec59f --- /dev/null +++ b/electron/types/adm-zip.d.ts @@ -0,0 +1,9 @@ +declare module 'adm-zip' { + class AdmZip { + constructor(path?: string) + addLocalFolder(localPath: string, zipPath?: string): void + writeZip(targetPath: string): void + } + + export = AdmZip +} diff --git a/package.json b/package.json index 7295531..f8599d8 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,7 @@ "files": [ "dist/**/*", "dist-electron/**/*", + "sikll/**/*", "!node_modules/**/*.{txt,md,js.map,ts,html}", "!node_modules/**/test/**/*", "!node_modules/**/docs/**/*", diff --git a/sikll/ct-mcp-copilot/.skill-meta.json b/sikll/ct-mcp-copilot/.skill-meta.json new file mode 100644 index 0000000..6c2be19 --- /dev/null +++ b/sikll/ct-mcp-copilot/.skill-meta.json @@ -0,0 +1,5 @@ +{ + "name": "ct-mcp-copilot", + "version": "1.0.0", + "description": "Use CipherTalk MCP as an AI copilot for fuzzy contact lookup, session resolution, search, context retrieval, and export follow-up questions." +} diff --git a/src/pages/McpPage.tsx b/src/pages/McpPage.tsx index 87c54e3..ac87128 100644 --- a/src/pages/McpPage.tsx +++ b/src/pages/McpPage.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, + Chip, Container, Snackbar, Stack, @@ -13,8 +14,9 @@ import { TextField, Typography, } from '@mui/material' -import { Check, Copy, Save } from 'lucide-react' +import { Check, Copy, Download, RefreshCw, Save, Sparkles } from 'lucide-react' import * as configService from '../services/config' +import type { SkillInstallTarget } from '../types/electron' type ToastState = { text: string @@ -90,10 +92,15 @@ const secondaryButtonSx = { } function McpPage() { + const managedSkillName = 'ct-mcp-copilot' const [mcpEnabled, setMcpEnabled] = useState(false) const [mcpExposeMediaPaths, setMcpExposeMediaPaths] = useState(true) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [skillTargets, setSkillTargets] = useState([]) + const [detectingSkills, setDetectingSkills] = useState(false) + const [installingSkill, setInstallingSkill] = useState(false) + const [exportingSkillZip, setExportingSkillZip] = useState(false) const [toast, setToast] = useState(null) const [launchConfig, setLaunchConfig] = useState({ command: 'npm', @@ -123,6 +130,13 @@ function McpPage() { console.error('获取 MCP 启动配置失败:', innerError) } } + + try { + const targets = await window.electronAPI.skillInstaller.detectTargets(managedSkillName) + setSkillTargets(targets) + } catch (innerError) { + console.error('检测 Skills 安装目标失败:', innerError) + } } catch (e) { console.error('加载 MCP 配置失败:', e) setToast({ text: '加载 MCP 配置失败', success: false }) @@ -174,6 +188,57 @@ function McpPage() { } } + const detectSkillTargets = async () => { + setDetectingSkills(true) + try { + const targets = await window.electronAPI.skillInstaller.detectTargets(managedSkillName) + setSkillTargets(targets) + setToast({ text: '已刷新 Skills 安装目标', success: true }) + } catch (e) { + console.error('检测 Skills 安装目标失败:', e) + setToast({ text: '检测 Skills 安装目标失败', success: false }) + } finally { + setDetectingSkills(false) + } + } + + const installManagedSkill = async () => { + setInstallingSkill(true) + try { + const result = await window.electronAPI.skillInstaller.installSkill(managedSkillName) + setSkillTargets(result.results) + if (result.success) { + setToast({ text: `${managedSkillName} 已安装到支持的 Agent`, success: true }) + } else { + setToast({ text: result.error || 'Skill 安装失败', success: false }) + } + } catch (e) { + console.error('安装 Skill 失败:', e) + setToast({ text: '安装 Skill 失败', success: false }) + } finally { + setInstallingSkill(false) + } + } + + const exportManagedSkillZip = async () => { + setExportingSkillZip(true) + try { + const result = await window.electronAPI.skillInstaller.exportSkillZip(managedSkillName) + if (result.success) { + setToast({ text: `Skill 压缩包已导出到 ${result.outputPath}`, success: true }) + } else { + setToast({ text: result.error || '导出 Skill 压缩包失败', success: false }) + } + } catch (e) { + console.error('导出 Skill 压缩包失败:', e) + setToast({ text: '导出 Skill 压缩包失败', success: false }) + } finally { + setExportingSkillZip(false) + } + } + + const bundledSkillVersion = skillTargets[0]?.bundledVersion || '1.0.0' + return ( @@ -362,6 +427,175 @@ function McpPage() { + + + + + + + 内置 Skill `ct-mcp-copilot` 可帮助支持 Skills 的 Agent 更聪明地使用 CipherTalk MCP 做模糊联系人查找、会话定位和导出补问。 + + + + + + 内置 Skill 版本 + + 当前内置版本:`{bundledSkillVersion}`。如果本机已安装版本更低,页面会提示可更新。 + + + + + + + + + + 一键安装到本机 Agent + + 自动探测 Codex、`.agents` 以及主目录下更多可能的 skills 目录,并把 `ct-mcp-copilot` 复制安装进去。 + + + + + + + + + + + {skillTargets.map((target) => ( + + + + + + {target.agentLabel} + + + {target.updateAvailable && ( + + )} + + + + {target.installPath || target.skillsDir} + + + 已安装版本:{target.installedVersion || '未安装'} / 内置版本:{target.bundledVersion} + + {target.error && ( + + {target.error} + + )} + + + + ))} + {skillTargets.length === 0 && ( + + 还没有检测到本机 Skill 目标,点击“检测目标”后可查看支持情况。 + + )} + + + + 安装完成后,可在支持 Skills 的 Agent 中直接提到 `ct-mcp-copilot` 使用;也可以导出 `zip` 后手动导入。Cherry Studio 等 MCP 宿主仍然继续使用 `mcpServers` 配置,不属于 skills 目录安装模型。 + + + + diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index d85b167..b902a96 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -2,6 +2,20 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models' import type { SummaryResult } from './ai' import type { AccountProfile } from './account' +export interface SkillInstallTarget { + agentKind: 'codex' | 'agents' + agentLabel: string + source: 'known' | 'discovered' + skillsDir: string + supported: boolean + installed: boolean + bundledVersion: string + installedVersion?: string + updateAvailable: boolean + installPath?: string + error?: string +} + export interface ImageListItem { imagePath: string liveVideoPath?: string @@ -66,6 +80,11 @@ export interface ElectronAPI { update: (accountId: string, patch: Partial>) => Promise delete: (accountId: string, deleteLocalData?: boolean) => Promise<{ success: boolean; error?: string; deleted?: AccountProfile | null; nextActiveAccountId?: string }> } + skillInstaller: { + detectTargets: (skillName: string) => Promise + installSkill: (skillName: string) => Promise<{ success: boolean; results: SkillInstallTarget[]; error?: string }> + exportSkillZip: (skillName: string) => Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }> + } db: { open: (dbPath: string, key?: string) => Promise query: (sql: string, params?: unknown[]) => Promise diff --git a/tsconfig.node.json b/tsconfig.node.json index 59d9145..d262d28 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -9,5 +9,5 @@ "allowSyntheticDefaultImports": true, "strict": true }, - "include": ["vite.config.ts", "electron/**/*.ts"] + "include": ["vite.config.ts", "electron/**/*.ts", "src/types/account.ts"] }