mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-11 14:01:48 +08:00
feat: mcp客户端集成、外部skills导入
This commit is contained in:
@@ -64,6 +64,5 @@ native/image-decrypt/
|
||||
native/image-decrypt/target
|
||||
resources/whisper
|
||||
xkey
|
||||
skills
|
||||
.claude/
|
||||
.tmp
|
||||
|
||||
+55
-3
@@ -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
@@ -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 }>>,
|
||||
},
|
||||
|
||||
// 数据库操作
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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/**/*",
|
||||
|
||||
@@ -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.
|
||||
---
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Vendored
+18
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user