feat: mcp客户端集成、外部skills导入

This commit is contained in:
patricLee
2026-04-28 17:28:51 +08:00
parent 68998fa45d
commit 2f0d1da3e8
17 changed files with 1568 additions and 504 deletions
-1
View File
@@ -64,6 +64,5 @@ native/image-decrypt/
native/image-decrypt/target
resources/whisper
xkey
skills
.claude/
.tmp
+55 -3
View File
@@ -34,7 +34,8 @@ 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'
import { skillManagerService } from './services/skillManagerService'
import { mcpClientService } from './services/mcpClientService'
import { getElectronWorkerEnv } from './services/workerEnvironment'
type AppWithQuitFlag = typeof app & {
@@ -1607,8 +1608,53 @@ function registerIpcHandlers() {
return { success: true, deleted: result.deleted, nextActiveAccountId: result.nextActiveAccountId }
})
ipcMain.handle('skillInstaller:exportSkillZip', async (_, skillName: string) => {
return skillInstallerService.exportSkillZip(skillName)
// ── Skill Manager ──
ipcMain.handle('skillManager:list', async () => {
return skillManagerService.listSkills()
})
ipcMain.handle('skillManager:readContent', async (_, skillName: string) => {
return skillManagerService.readSkillContent(skillName)
})
ipcMain.handle('skillManager:updateContent', async (_, skillName: string, content: string) => {
return skillManagerService.updateSkillContent(skillName, content)
})
ipcMain.handle('skillManager:exportZip', async (_, skillName: string) => {
return skillManagerService.exportSkillZip(skillName)
})
ipcMain.handle('skillManager:importZip', async (_, zipPath: string) => {
return skillManagerService.importSkillZip(zipPath)
})
ipcMain.handle('skillManager:delete', async (_, skillName: string) => {
return skillManagerService.deleteSkill(skillName)
})
ipcMain.handle('skillManager:create', async (_, skillName: string, content: string) => {
return skillManagerService.createSkill(skillName, content)
})
// ── MCP Client ──
ipcMain.handle('mcpClient:listConfigs', async () => {
return mcpClientService.listClientConfigs()
})
ipcMain.handle('mcpClient:saveConfig', async (_, name: string, config: any, overwrite?: boolean) => {
return mcpClientService.saveClientConfig(name, config, Boolean(overwrite))
})
ipcMain.handle('mcpClient:deleteConfig', async (_, name: string) => {
return mcpClientService.deleteClientConfig(name)
})
ipcMain.handle('mcpClient:connect', async (_, name: string) => {
return mcpClientService.connectToServer(name)
})
ipcMain.handle('mcpClient:disconnect', async (_, name: string) => {
return mcpClientService.disconnectFromServer(name)
})
ipcMain.handle('mcpClient:listTools', async (_, name: string) => {
return mcpClientService.listToolsFromServer(name)
})
ipcMain.handle('mcpClient:callTool', async (_, name: string, toolName: string, args: any) => {
return mcpClientService.callTool(name, toolName, args)
})
ipcMain.handle('mcpClient:listStatuses', async () => {
return mcpClientService.listAllServerStatuses()
})
// HTTP API 管理
@@ -5123,6 +5169,9 @@ app.whenReady().then(async () => {
console.error('[McpProxy] 启动失败:', mcpProxyStartResult.error)
logService?.error('McpProxy', '内部 MCP 代理启动失败', { error: mcpProxyStartResult.error })
}
mcpClientService.restoreSavedConnections().catch((e) => {
console.error('[McpClient] 自动恢复连接失败:', e)
})
// 只有在配置完整时才创建主窗口
// 如果配置不完整,checkAndConnectOnStartup 会创建引导窗口
@@ -5168,6 +5217,9 @@ app.on('before-quit', () => {
mcpProxyService.stop().catch((e) => {
console.error('[McpProxy] 停止失败:', e)
})
mcpClientService.disconnectAll(false).catch((e) => {
console.error('[McpClient] 停止失败:', e)
})
// 关闭配置数据库连接
configService?.close()
+19 -3
View File
@@ -119,9 +119,25 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('accounts:delete', accountId, deleteLocalData) as Promise<{ success: boolean; error?: string; deleted?: AccountProfile | null; nextActiveAccountId?: string }>
},
skillInstaller: {
exportSkillZip: (skillName: string) =>
ipcRenderer.invoke('skillInstaller:exportSkillZip', skillName) as Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }>
skillManager: {
list: () => ipcRenderer.invoke('skillManager:list') as Promise<Array<{ name: string; version: string; description: string; builtin: boolean }>>,
readContent: (skillName: string) => ipcRenderer.invoke('skillManager:readContent', skillName) as Promise<{ success: boolean; content?: string; error?: string }>,
updateContent: (skillName: string, content: string) => ipcRenderer.invoke('skillManager:updateContent', skillName, content) as Promise<{ success: boolean; error?: string }>,
exportZip: (skillName: string) => ipcRenderer.invoke('skillManager:exportZip', skillName) as Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }>,
importZip: (zipPath: string) => ipcRenderer.invoke('skillManager:importZip', zipPath) as Promise<{ success: boolean; skillName?: string; error?: string }>,
delete: (skillName: string) => ipcRenderer.invoke('skillManager:delete', skillName) as Promise<{ success: boolean; error?: string }>,
create: (skillName: string, content: string) => ipcRenderer.invoke('skillManager:create', skillName, content) as Promise<{ success: boolean; error?: string }>,
},
mcpClient: {
listConfigs: () => ipcRenderer.invoke('mcpClient:listConfigs') as Promise<Record<string, { type: string; command?: string; args?: string[]; env?: Record<string, string>; cwd?: string; url?: string; headers?: Record<string, string>; timeoutMs?: number; autoConnect?: boolean }>>,
saveConfig: (name: string, config: any, overwrite?: boolean) => ipcRenderer.invoke('mcpClient:saveConfig', name, config, overwrite) as Promise<{ success: boolean; error?: string }>,
deleteConfig: (name: string) => ipcRenderer.invoke('mcpClient:deleteConfig', name) as Promise<{ success: boolean; error?: string }>,
connect: (name: string) => ipcRenderer.invoke('mcpClient:connect', name) as Promise<{ success: boolean; tools?: Array<{ name: string; description?: string }>; error?: string }>,
disconnect: (name: string) => ipcRenderer.invoke('mcpClient:disconnect', name) as Promise<{ success: boolean; error?: string }>,
listTools: (name: string) => ipcRenderer.invoke('mcpClient:listTools', name) as Promise<{ success: boolean; tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>; error?: string }>,
callTool: (name: string, toolName: string, args: any) => ipcRenderer.invoke('mcpClient:callTool', name, toolName, args) as Promise<{ success: boolean; result?: any; error?: string }>,
listStatuses: () => ipcRenderer.invoke('mcpClient:listStatuses') as Promise<Array<{ name: string; config: any; status: string; toolCount: number; error?: string }>>,
},
// 数据库操作
+325
View File
@@ -0,0 +1,325 @@
import { app } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { ChildProcess } from 'child_process'
export type McpTransportType = 'stdio' | 'sse' | 'http'
export type McpClientServerConfig = {
type: McpTransportType
command?: string
args?: string[]
env?: Record<string, string>
cwd?: string
url?: string
headers?: Record<string, string>
timeoutMs?: number
autoConnect?: boolean
}
export type McpServerStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
export type McpServerInfo = {
name: string
config: McpClientServerConfig
status: McpServerStatus
toolCount: number
error?: string
}
export type McpToolInfo = {
name: string
description?: string
inputSchema?: unknown
}
type ClientConnection = {
client: InstanceType<typeof import('@modelcontextprotocol/sdk/client/index.js').Client>
transport: unknown
process?: ChildProcess
tools: McpToolInfo[]
}
const CONFIG_FILE = 'mcp-client-configs.json'
function normalizeTimeoutMs(value?: number): number | undefined {
if (!value || !Number.isFinite(value) || value <= 0) return undefined
return Math.round(value)
}
function mergeHeaders(base: RequestInit['headers'] | undefined, extra: Record<string, string> | undefined): Headers {
const headers = new Headers(base)
if (extra) {
for (const [key, value] of Object.entries(extra)) {
if (key.trim()) headers.set(key.trim(), value)
}
}
return headers
}
function createFetchWithOptions(headers?: Record<string, string>, timeoutMs?: number): typeof fetch {
return async (input, init = {}) => {
const controller = new AbortController()
let timer: NodeJS.Timeout | undefined
const upstreamSignal = init.signal
if (upstreamSignal) {
if (upstreamSignal.aborted) controller.abort(upstreamSignal.reason)
else upstreamSignal.addEventListener('abort', () => controller.abort(upstreamSignal.reason), { once: true })
}
if (timeoutMs) {
timer = setTimeout(() => controller.abort(new Error(`MCP request timed out after ${timeoutMs}ms`)), timeoutMs)
}
try {
return await fetch(input, {
...init,
headers: mergeHeaders(init.headers, headers),
signal: controller.signal,
})
} finally {
if (timer) clearTimeout(timer)
}
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number | undefined, label: string): Promise<T> {
if (!timeoutMs) return promise
let timer: NodeJS.Timeout | undefined
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}
function getConfigPath(): string {
return join(app.getPath('userData'), CONFIG_FILE)
}
function loadConfigs(): Record<string, McpClientServerConfig> {
const p = getConfigPath()
if (!existsSync(p)) return {}
try {
return JSON.parse(readFileSync(p, 'utf8'))
} catch {
return {}
}
}
function saveConfigs(configs: Record<string, McpClientServerConfig>): void {
writeFileSync(getConfigPath(), JSON.stringify(configs, null, 2), 'utf8')
}
export class McpClientService {
private connections = new Map<string, ClientConnection>()
private pendingStatuses = new Map<string, McpServerStatus>()
private lastErrors = new Map<string, string>()
listClientConfigs(): Record<string, McpClientServerConfig> {
return loadConfigs()
}
saveClientConfig(name: string, config: McpClientServerConfig, overwrite = false): { success: boolean; error?: string } {
if (!name.trim()) return { success: false, error: 'Server name is required' }
const configs = loadConfigs()
if (configs[name] && !overwrite) return { success: false, error: `Server "${name}" already exists` }
if (configs[name]?.autoConnect !== undefined && config.autoConnect === undefined) {
config.autoConnect = configs[name].autoConnect
}
configs[name] = config
saveConfigs(configs)
return { success: true }
}
private setAutoConnect(name: string, enabled: boolean): void {
const configs = loadConfigs()
if (!configs[name]) return
configs[name] = { ...configs[name], autoConnect: enabled }
saveConfigs(configs)
}
deleteClientConfig(name: string): { success: boolean; error?: string } {
const configs = loadConfigs()
if (!configs[name]) return { success: false, error: `Server "${name}" not found` }
void this.disconnectFromServer(name)
delete configs[name]
saveConfigs(configs)
return { success: true }
}
async connectToServer(name: string): Promise<{ success: boolean; tools?: McpToolInfo[]; error?: string }> {
if (this.connections.has(name)) {
return { success: false, error: `Already connected to "${name}"` }
}
if (this.pendingStatuses.get(name) === 'connecting') {
return { success: false, error: `Already connecting to "${name}"` }
}
const configs = loadConfigs()
const config = configs[name]
if (!config) return { success: false, error: `Server "${name}" not found` }
this.pendingStatuses.set(name, 'connecting')
this.lastErrors.delete(name)
try {
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
const client = new Client({ name: `ciphertalk-client-${name}`, version: '1.0.0' })
const timeoutMs = normalizeTimeoutMs(config.timeoutMs)
let transport: unknown
if (config.type === 'stdio') {
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
transport = new StdioClientTransport({
command: config.command || '',
args: config.args,
env: config.env as Record<string, string> | undefined,
cwd: config.cwd,
})
} else if (config.type === 'sse') {
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js')
const requestInit = config.headers ? { headers: config.headers } : undefined
const fetchWithOptions = createFetchWithOptions(config.headers, timeoutMs)
transport = new SSEClientTransport(new URL(config.url || ''), {
requestInit,
eventSourceInit: { fetch: fetchWithOptions },
fetch: fetchWithOptions,
})
} else if (config.type === 'http') {
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js')
transport = new StreamableHTTPClientTransport(new URL(config.url || ''), {
requestInit: config.headers ? { headers: config.headers } : undefined,
fetch: createFetchWithOptions(config.headers, timeoutMs),
})
} else {
return { success: false, error: `Unsupported transport type: ${config.type}` }
}
await withTimeout(
client.connect(transport as import('@modelcontextprotocol/sdk/shared/transport.js').Transport),
timeoutMs,
'MCP connection'
)
const toolsResult = await withTimeout(client.listTools(), timeoutMs, 'MCP listTools')
const tools: McpToolInfo[] = (toolsResult.tools || []).map(t => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
}))
this.connections.set(name, { client, transport, tools })
this.pendingStatuses.delete(name)
this.lastErrors.delete(name)
this.setAutoConnect(name, true)
return { success: true, tools }
} catch (e) {
const error = String(e)
this.pendingStatuses.delete(name)
this.lastErrors.set(name, error)
return { success: false, error }
}
}
async disconnectFromServer(name: string, rememberManualDisconnect = true): Promise<{ success: boolean; error?: string }> {
const conn = this.connections.get(name)
if (!conn) {
if (rememberManualDisconnect) this.setAutoConnect(name, false)
this.pendingStatuses.delete(name)
return { success: false, error: `Not connected to "${name}"` }
}
try {
await conn.client.close()
this.connections.delete(name)
this.pendingStatuses.delete(name)
this.lastErrors.delete(name)
if (rememberManualDisconnect) this.setAutoConnect(name, false)
return { success: true }
} catch (e) {
this.connections.delete(name)
this.pendingStatuses.delete(name)
if (rememberManualDisconnect) this.setAutoConnect(name, false)
return { success: false, error: String(e) }
}
}
async listToolsFromServer(name: string): Promise<{ success: boolean; tools?: McpToolInfo[]; error?: string }> {
const conn = this.connections.get(name)
if (!conn) return { success: false, error: `Not connected to "${name}"` }
try {
const toolsResult = await conn.client.listTools()
const tools: McpToolInfo[] = (toolsResult.tools || []).map(t => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
}))
conn.tools = tools
return { success: true, tools }
} catch (e) {
return { success: false, error: String(e) }
}
}
async callTool(name: string, toolName: string, args: Record<string, unknown>): Promise<{ success: boolean; result?: unknown; error?: string }> {
const conn = this.connections.get(name)
if (!conn) return { success: false, error: `Not connected to "${name}"` }
try {
const result = await conn.client.callTool({ name: toolName, arguments: args })
return { success: true, result }
} catch (e) {
return { success: false, error: String(e) }
}
}
getServerStatus(name: string): McpServerStatus {
const pending = this.pendingStatuses.get(name)
if (pending) return pending
if (this.lastErrors.has(name)) return 'error'
return this.connections.has(name) ? 'connected' : 'disconnected'
}
listAllServerStatuses(): McpServerInfo[] {
const configs = loadConfigs()
const results: McpServerInfo[] = []
for (const [name, config] of Object.entries(configs)) {
const conn = this.connections.get(name)
const status = this.getServerStatus(name)
results.push({
name,
config,
status,
toolCount: conn?.tools.length ?? 0,
error: this.lastErrors.get(name),
})
}
return results
}
async restoreSavedConnections(): Promise<void> {
const configs = loadConfigs()
const targets = Object.entries(configs)
.filter(([, config]) => config.autoConnect)
.map(([name]) => name)
await Promise.allSettled(targets.map(name => this.connectToServer(name)))
}
async disconnectAll(rememberManualDisconnect = false): Promise<void> {
const names = [...this.connections.keys()]
await Promise.allSettled(names.map(n => this.disconnectFromServer(n, rememberManualDisconnect)))
}
}
export const mcpClientService = new McpClientService()
@@ -1,97 +0,0 @@
import { app } from 'electron'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import AdmZip from 'adm-zip'
type SkillSource = {
name: string
relativePaths: string[]
}
type SkillMeta = {
name: string
version: string
description?: string
}
const MANAGED_SKILLS: Record<string, SkillSource> = {
'ct-mcp-copilot': {
name: 'ct-mcp-copilot',
relativePaths: [
'ct-mcp-copilot',
join('skills', 'ct-mcp-copilot'),
join('sikll', 'ct-mcp-copilot')
]
}
}
export class SkillInstallerService {
private listSkillSourceCandidates(skillName: string): string[] {
const source = MANAGED_SKILLS[skillName]
if (!source) return []
const roots = [
join(process.resourcesPath, 'builtin-skills'),
app.getAppPath(),
join(process.resourcesPath, 'app.asar'),
join(process.resourcesPath, 'app.asar.unpacked'),
process.resourcesPath,
process.cwd()
]
const seen = new Set<string>()
const candidates: string[] = []
for (const root of roots) {
for (const relativePath of source.relativePaths) {
const candidate = join(root, relativePath)
const normalized = candidate.toLowerCase()
if (seen.has(normalized)) continue
seen.add(normalized)
candidates.push(candidate)
}
}
return candidates
}
private getSkillSourcePath(skillName: string): string | null {
for (const candidate of this.listSkillSourceCandidates(skillName)) {
if (existsSync(join(candidate, 'SKILL.md'))) return candidate
}
return null
}
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
}
}
exportSkillZip(skillName: string): { success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string } {
const sourcePath = this.getSkillSourcePath(skillName)
if (!sourcePath) {
const tried = this.listSkillSourceCandidates(skillName)
return { success: false, error: `Skill source not found for ${skillName}. Tried: ${tried.join(' | ')}` }
}
try {
const downloadsDir = app.getPath('downloads')
const version = this.readSkillMeta(sourcePath)?.version || '0.0.0'
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()
+294
View File
@@ -0,0 +1,294 @@
import { app } from 'electron'
import { existsSync, readFileSync, writeFileSync, readdirSync, rmSync, mkdirSync, mkdtempSync, renameSync } from 'fs'
import { join } from 'path'
import AdmZip from 'adm-zip'
type AdmZipFull = InstanceType<typeof AdmZip> & {
getEntries(): Array<{ entryName: string }>
extractAllTo(targetPath: string, overwrite: boolean): void
}
export type SkillInfo = {
name: string
version: string
description: string
builtin: boolean
}
const BUILTIN_SKILLS = new Set(['ct-mcp-copilot'])
function parseSkillFrontmatter(content: string): { name: string; version: string; description: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
const raw = match?.[1] ?? ''
const values: Record<string, string> = {}
const lines = raw.split(/\r?\n/)
let index = 0
while (index < lines.length) {
const line = lines[index]
const matchLine = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
if (!matchLine) {
index += 1
continue
}
const key = matchLine[1]
const value = matchLine[2].trim()
if (value === '>' || value === '|') {
const blockLines: string[] = []
index += 1
while (index < lines.length && (/^\s+/.test(lines[index]) || lines[index].trim() === '')) {
blockLines.push(lines[index].trim())
index += 1
}
values[key] = value === '>' ? blockLines.join(' ').replace(/\s+/g, ' ').trim() : blockLines.join('\n').trim()
continue
}
values[key] = value.replace(/^['"]|['"]$/g, '')
index += 1
}
return {
name: values.name || '',
version: values.version || '0.0.0',
description: values.description || '',
}
}
function getSkillRoots(): string[] {
return [
join(process.resourcesPath, 'builtin-skills'),
join(process.resourcesPath, 'app.asar'),
join(process.resourcesPath, 'app.asar.unpacked'),
app.getAppPath(),
process.cwd(),
]
}
function resolveSkillDir(skillName: string): string | null {
for (const root of getSkillRoots()) {
const candidate = join(root, skillName)
if (existsSync(join(candidate, 'SKILL.md'))) return candidate
const alt = join(root, 'skills', skillName)
if (existsSync(join(alt, 'SKILL.md'))) return alt
}
return null
}
function getUserSkillsDir(): string {
return join(app.getPath('userData'), 'skills')
}
function scanSkillDir(baseDir: string, builtin: boolean): SkillInfo[] {
if (!existsSync(baseDir)) return []
const results: SkillInfo[] = []
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const skillMdPath = join(baseDir, entry.name, 'SKILL.md')
if (!existsSync(skillMdPath)) continue
try {
const content = readFileSync(skillMdPath, 'utf8')
const meta = parseSkillFrontmatter(content)
results.push({
name: meta.name || entry.name,
version: meta.version,
description: meta.description,
builtin,
})
} catch {
results.push({ name: entry.name, version: '0.0.0', description: '', builtin })
}
}
return results
}
function findSkillDirectoryRecursive(baseDir: string): string | null {
if (!existsSync(baseDir)) return null
if (existsSync(join(baseDir, 'SKILL.md'))) return baseDir
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
const entryPath = join(baseDir, entry.name)
if (!entry.isDirectory()) continue
if (existsSync(join(entryPath, 'SKILL.md'))) return entryPath
const nested = findSkillDirectoryRecursive(entryPath)
if (nested) return nested
}
return null
}
function getSkillNameFromDir(dir: string): string {
try {
const content = readFileSync(join(dir, 'SKILL.md'), 'utf8')
return parseSkillFrontmatter(content).name || dir.split(/[\\/]/).pop() || 'skill'
} catch {
return dir.split(/[\\/]/).pop() || 'skill'
}
}
export class SkillManagerService {
listSkills(): SkillInfo[] {
const results: SkillInfo[] = []
const seen = new Set<string>()
for (const root of getSkillRoots()) {
for (const skill of scanSkillDir(root, true)) {
if (!seen.has(skill.name)) {
seen.add(skill.name)
results.push(skill)
}
}
for (const skill of scanSkillDir(join(root, 'skills'), true)) {
if (!seen.has(skill.name)) {
seen.add(skill.name)
results.push(skill)
}
}
}
for (const skill of scanSkillDir(getUserSkillsDir(), false)) {
if (!seen.has(skill.name)) {
seen.add(skill.name)
results.push(skill)
}
}
return results
}
readSkillContent(skillName: string): { success: boolean; content?: string; error?: string } {
const dir = resolveSkillDir(skillName) ?? this.resolveUserSkillDir(skillName)
if (!dir) return { success: false, error: `Skill "${skillName}" not found` }
try {
const content = readFileSync(join(dir, 'SKILL.md'), 'utf8')
return { success: true, content }
} catch (e) {
return { success: false, error: String(e) }
}
}
updateSkillContent(skillName: string, content: string): { success: boolean; error?: string } {
const dir = this.resolveUserSkillDir(skillName)
if (!dir) return { success: false, error: `User skill "${skillName}" not found. Only user-imported skills can be edited.` }
try {
writeFileSync(join(dir, 'SKILL.md'), content, 'utf8')
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
exportSkillZip(skillName: string): { success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string } {
const sourcePath = resolveSkillDir(skillName) ?? this.resolveUserSkillDir(skillName)
if (!sourcePath) return { success: false, error: `Skill "${skillName}" not found` }
try {
const content = readFileSync(join(sourcePath, 'SKILL.md'), 'utf8')
const meta = parseSkillFrontmatter(content)
const downloadsDir = app.getPath('downloads')
const version = meta.version || '0.0.0'
const fileName = `${skillName}-v${version}.zip`
const outputPath = join(downloadsDir, fileName)
const zip: AdmZipFull = new AdmZip() as AdmZipFull
zip.addLocalFolder(sourcePath, skillName)
zip.writeZip(outputPath)
return { success: true, outputPath, fileName, version }
} catch (error) {
return { success: false, error: String(error) }
}
}
importSkillZip(zipPath: string): { success: boolean; skillName?: string; error?: string } {
let tempDir: string | null = null
try {
const zip: AdmZipFull = new AdmZip(zipPath) as AdmZipFull
const entries = zip.getEntries()
if (entries.length === 0) return { success: false, error: 'Zip file is empty' }
if (!entries.some((e: { entryName: string }) => e.entryName.split('/').pop() === 'SKILL.md')) {
return { success: false, error: 'No SKILL.md found in zip' }
}
const userSkillsDir = getUserSkillsDir()
if (!existsSync(userSkillsDir)) {
mkdirSync(userSkillsDir, { recursive: true })
}
tempDir = mkdtempSync(join(app.getPath('userData'), 'skill-import-'))
zip.extractAllTo(tempDir, true)
const extractedDir = findSkillDirectoryRecursive(tempDir)
if (!extractedDir) {
return { success: false, error: 'Skill extracted but SKILL.md not found' }
}
const skillName = getSkillNameFromDir(extractedDir)
if (this.resolveUserSkillDir(skillName) || resolveSkillDir(skillName)) {
return { success: false, error: `Skill "${skillName}" already exists` }
}
const destDir = join(userSkillsDir, skillName)
if (existsSync(destDir)) {
return { success: false, error: `Skill directory "${skillName}" already exists` }
}
renameSync(extractedDir, destDir)
return { success: true, skillName }
} catch (error) {
return { success: false, error: String(error) }
} finally {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true })
}
}
}
deleteSkill(skillName: string): { success: boolean; error?: string } {
if (BUILTIN_SKILLS.has(skillName)) {
return { success: false, error: `Cannot delete builtin skill "${skillName}"` }
}
const dir = this.resolveUserSkillDir(skillName)
if (!dir) return { success: false, error: `Skill "${skillName}" not found` }
try {
rmSync(dir, { recursive: true, force: true })
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
createSkill(skillName: string, content: string): { success: boolean; error?: string } {
const userSkillsDir = getUserSkillsDir()
const destDir = join(userSkillsDir, skillName)
if (existsSync(destDir)) {
return { success: false, error: `Skill "${skillName}" already exists` }
}
try {
mkdirSync(destDir, { recursive: true })
writeFileSync(join(destDir, 'SKILL.md'), content, 'utf8')
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
private resolveUserSkillDir(skillName: string): string | null {
const dir = join(getUserSkillsDir(), skillName)
if (existsSync(join(dir, 'SKILL.md'))) return dir
const userSkillsDir = getUserSkillsDir()
if (!existsSync(userSkillsDir)) return null
for (const entry of readdirSync(userSkillsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const candidate = join(userSkillsDir, entry.name)
if (!existsSync(join(candidate, 'SKILL.md'))) continue
if (getSkillNameFromDir(candidate) === skillName) return candidate
}
return null
}
}
export const skillManagerService = new SkillManagerService()
+2 -2
View File
@@ -181,7 +181,7 @@
"to": "release-announcement.json"
},
{
"from": "sikll/ct-mcp-copilot",
"from": "skills/ct-mcp-copilot",
"to": "builtin-skills/ct-mcp-copilot"
}
],
@@ -198,7 +198,7 @@
"files": [
"dist/**/*",
"dist-electron/**/*",
"sikll/**/*",
"skills/**/*",
"!node_modules/**/*.{txt,md,js.map,ts,html}",
"!node_modules/**/test/**/*",
"!node_modules/**/docs/**/*",
-5
View File
@@ -1,5 +0,0 @@
{
"name": "ct-mcp-copilot",
"version": "1.2.0",
"description": "Use CipherTalk MCP as an AI copilot for fuzzy contact lookup, session resolution, search, context retrieval, and export follow-up questions."
}
@@ -1,5 +1,6 @@
---
name: ct-mcp-copilot
version: '1.2.0'
description: Use CipherTalk MCP as an AI copilot for health/status checks, contact lookup, session resolution, message search, context retrieval, moments timeline exploration, chat export, and chat analytics. Trigger when the user provides partial, fuzzy, mistaken, or incomplete clues, or wants the AI to proactively dig for more local data instead of stopping after one failed query.
---
+1 -1
View File
@@ -83,7 +83,7 @@ function Sidebar() {
{ key: 'export', label: '导出数据', icon: <Download size={20} />, type: 'route', path: '/export' },
{ key: 'data-management', label: '数据管理', icon: <Database size={20} />, type: 'route', path: '/data-management' },
{ key: 'open-api', label: '开放接口', icon: <Network size={20} />, type: 'route', path: '/open-api' },
{ key: 'mcp', label: 'MCP 服务', icon: <MCP size={20} />, type: 'route', path: '/mcp' },
{ key: 'mcp', label: 'MCP & Skills', icon: <MCP size={20} />, type: 'route', path: '/mcp' },
]
const navItemSx = {
+853 -390
View File
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -113,8 +113,24 @@ export interface ElectronAPI {
update: (accountId: string, patch: Partial<Omit<AccountProfile, 'id' | 'createdAt' | 'updatedAt' | 'lastUsedAt'>>) => Promise<AccountProfile | null>
delete: (accountId: string, deleteLocalData?: boolean) => Promise<{ success: boolean; error?: string; deleted?: AccountProfile | null; nextActiveAccountId?: string }>
}
skillInstaller: {
exportSkillZip: (skillName: string) => Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }>
skillManager: {
list: () => Promise<Array<{ name: string; version: string; description: string; builtin: boolean }>>
readContent: (skillName: string) => Promise<{ success: boolean; content?: string; error?: string }>
updateContent: (skillName: string, content: string) => Promise<{ success: boolean; error?: string }>
exportZip: (skillName: string) => Promise<{ success: boolean; outputPath?: string; fileName?: string; version?: string; error?: string }>
importZip: (zipPath: string) => Promise<{ success: boolean; skillName?: string; error?: string }>
delete: (skillName: string) => Promise<{ success: boolean; error?: string }>
create: (skillName: string, content: string) => Promise<{ success: boolean; error?: string }>
}
mcpClient: {
listConfigs: () => Promise<Record<string, { type: string; command?: string; args?: string[]; env?: Record<string, string>; cwd?: string; url?: string; headers?: Record<string, string>; timeoutMs?: number; autoConnect?: boolean }>>
saveConfig: (name: string, config: { type: string; command?: string; args?: string[]; env?: Record<string, string>; cwd?: string; url?: string; headers?: Record<string, string>; timeoutMs?: number; autoConnect?: boolean }, overwrite?: boolean) => Promise<{ success: boolean; error?: string }>
deleteConfig: (name: string) => Promise<{ success: boolean; error?: string }>
connect: (name: string) => Promise<{ success: boolean; tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>; error?: string }>
disconnect: (name: string) => Promise<{ success: boolean; error?: string }>
listTools: (name: string) => Promise<{ success: boolean; tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>; error?: string }>
callTool: (name: string, toolName: string, args: Record<string, unknown>) => Promise<{ success: boolean; result?: unknown; error?: string }>
listStatuses: () => Promise<Array<{ name: string; config: { type: string; command?: string; args?: string[]; env?: Record<string, string>; cwd?: string; url?: string; headers?: Record<string, string>; timeoutMs?: number; autoConnect?: boolean }; status: string; toolCount: number; error?: string }>>
}
db: {
open: (dbPath: string, key?: string) => Promise<boolean>