mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-04-30 13:51:50 +08:00
feat: add bundled skill installer support
This commit is contained in:
38
README.md
38
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<skillVersion>.zip`
|
||||
- 默认导出到系统 Downloads 目录
|
||||
- 可用于手动导入到支持 skills 的 Agent
|
||||
|
||||
安装后可直接这样使用:
|
||||
|
||||
- `使用 ct-mcp-copilot 帮我查这个人`
|
||||
- `使用 ct-mcp-copilot 帮我补全导出问题`
|
||||
|
||||
说明:
|
||||
|
||||
- 安装器使用“复制安装”,不会创建软链接
|
||||
- 当前只管理内置 skill `ct-mcp-copilot`
|
||||
- 当前只检查本地已安装版本是否落后,不检查远程最新版本
|
||||
- Cherry Studio 等 MCP 宿主继续使用 `mcpServers` 配置,不属于 skills 目录安装模型
|
||||
|
||||
### 参数示例
|
||||
|
||||
```json
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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<SkillInstallTarget[]>,
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
236
electron/services/skillInstallerService.ts
Normal file
236
electron/services/skillInstallerService.ts
Normal file
@@ -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<string, SkillSource> = {
|
||||
'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<string>()
|
||||
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()
|
||||
@@ -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))
|
||||
|
||||
9
electron/types/adm-zip.d.ts
vendored
Normal file
9
electron/types/adm-zip.d.ts
vendored
Normal file
@@ -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
|
||||
}
|
||||
@@ -180,6 +180,7 @@
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*",
|
||||
"sikll/**/*",
|
||||
"!node_modules/**/*.{txt,md,js.map,ts,html}",
|
||||
"!node_modules/**/test/**/*",
|
||||
"!node_modules/**/docs/**/*",
|
||||
|
||||
5
sikll/ct-mcp-copilot/.skill-meta.json
Normal file
5
sikll/ct-mcp-copilot/.skill-meta.json
Normal file
@@ -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."
|
||||
}
|
||||
@@ -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<SkillInstallTarget[]>([])
|
||||
const [detectingSkills, setDetectingSkills] = useState(false)
|
||||
const [installingSkill, setInstallingSkill] = useState(false)
|
||||
const [exportingSkillZip, setExportingSkillZip] = useState(false)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
const [launchConfig, setLaunchConfig] = useState<McpLaunchConfig>({
|
||||
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 (
|
||||
<Box sx={{ height: '100%', mx: -3, mt: -3, overflowY: 'auto', pb: 3 }}>
|
||||
<Container maxWidth="lg" sx={{ px: { xs: 2, md: 4 }, py: { xs: 3, md: 4 } }}>
|
||||
@@ -362,6 +427,175 @@ function McpPage() {
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: '26px',
|
||||
border: '1px solid var(--border-color)',
|
||||
bgcolor: 'var(--bg-secondary)',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<CardHeader
|
||||
title="AI Copilot Skill"
|
||||
titleTypographyProps={{ fontWeight: 700, fontSize: 18, color: 'var(--text-primary)' }}
|
||||
sx={{ px: { xs: 2, md: 3 }, pb: 0.8 }}
|
||||
/>
|
||||
<CardContent sx={{ px: { xs: 2, md: 3 }, pt: 0.6 }}>
|
||||
<Stack spacing={2.2}>
|
||||
<Alert
|
||||
severity="info"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: '18px',
|
||||
bgcolor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-color)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
内置 Skill `ct-mcp-copilot` 可帮助支持 Skills 的 Agent 更聪明地使用 CipherTalk MCP 做模糊联系人查找、会话定位和导出补问。
|
||||
</Alert>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '18px',
|
||||
border: '1px solid var(--border-color)',
|
||||
bgcolor: 'var(--bg-primary)',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}>内置 Skill 版本</Typography>
|
||||
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
当前内置版本:`{bundledSkillVersion}`。如果本机已安装版本更低,页面会提示可更新。
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Download size={16} />}
|
||||
onClick={exportManagedSkillZip}
|
||||
disabled={exportingSkillZip}
|
||||
sx={secondaryButtonSx}
|
||||
>
|
||||
{exportingSkillZip ? '导出中...' : '导出 zip'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '18px',
|
||||
border: '1px solid var(--border-color)',
|
||||
bgcolor: 'var(--bg-primary)',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}>一键安装到本机 Agent</Typography>
|
||||
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
自动探测 Codex、`.agents` 以及主目录下更多可能的 skills 目录,并把 `ct-mcp-copilot` 复制安装进去。
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={detectSkillTargets}
|
||||
disabled={detectingSkills || installingSkill}
|
||||
sx={secondaryButtonSx}
|
||||
>
|
||||
{detectingSkills ? '检测中...' : '检测目标'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Sparkles size={16} />}
|
||||
onClick={installManagedSkill}
|
||||
disabled={installingSkill}
|
||||
sx={{
|
||||
minWidth: 140,
|
||||
borderRadius: '999px',
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'var(--primary-gradient)',
|
||||
'&:hover': {
|
||||
background: 'var(--primary-gradient)',
|
||||
filter: 'brightness(0.98)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{installingSkill ? '安装中...' : '一键安装'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1.2}>
|
||||
{skillTargets.map((target) => (
|
||||
<Box
|
||||
key={`${target.agentKind}-${target.skillsDir}`}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: '18px',
|
||||
border: '1px solid var(--border-color)',
|
||||
bgcolor: 'var(--bg-primary)',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" useFlexGap flexWrap="wrap">
|
||||
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{target.agentLabel}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={target.installed ? '已安装' : target.supported ? '可安装' : '不支持'}
|
||||
size="small"
|
||||
color={target.installed ? 'success' : target.supported ? 'primary' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
{target.updateAvailable && (
|
||||
<Chip
|
||||
label="可更新"
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={target.source === 'known' ? '内置规则' : '扫描发现'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
<Typography sx={{ mt: 0.75, fontSize: 13, color: 'var(--text-secondary)', wordBreak: 'break-all' }}>
|
||||
{target.installPath || target.skillsDir}
|
||||
</Typography>
|
||||
<Typography sx={{ mt: 0.5, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
已安装版本:{target.installedVersion || '未安装'} / 内置版本:{target.bundledVersion}
|
||||
</Typography>
|
||||
{target.error && (
|
||||
<Typography sx={{ mt: 0.75, fontSize: 12, color: 'var(--danger)' }}>
|
||||
{target.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
{skillTargets.length === 0 && (
|
||||
<Typography sx={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
还没有检测到本机 Skill 目标,点击“检测目标”后可查看支持情况。
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography sx={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
安装完成后,可在支持 Skills 的 Agent 中直接提到 `ct-mcp-copilot` 使用;也可以导出 `zip` 后手动导入。Cherry Studio 等 MCP 宿主仍然继续使用 `mcpServers` 配置,不属于 skills 目录安装模型。
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
|
||||
19
src/types/electron.d.ts
vendored
19
src/types/electron.d.ts
vendored
@@ -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<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: {
|
||||
detectTargets: (skillName: string) => Promise<SkillInstallTarget[]>
|
||||
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<boolean>
|
||||
query: <T = unknown>(sql: string, params?: unknown[]) => Promise<T[]>
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts", "electron/**/*.ts"]
|
||||
"include": ["vite.config.ts", "electron/**/*.ts", "src/types/account.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user