feat: 新增 CipherTalk MCP 服务

This commit is contained in:
ILoveBingLu
2026-04-01 19:41:54 +08:00
parent 2ca9f08ea5
commit f5ca04ad51
23 changed files with 1543 additions and 286 deletions
+2
View File
@@ -16,6 +16,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage'
import OpenApiPage from './pages/OpenApiPage'
import McpPage from './pages/McpPage'
import ExportPage from './pages/ExportPage'
import ActivationPage from './pages/ActivationPage'
import ImageWindow from './pages/ImageWindow'
@@ -513,6 +514,7 @@ function App() {
<Route path="/data-management" element={<DataManagementPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/open-api" element={<OpenApiPage />} />
<Route path="/mcp" element={<McpPage />} />
<Route path="/export" element={<ExportPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
</Routes>
+2 -1
View File
@@ -10,7 +10,7 @@ import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network } from 'lucide-react'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network, Boxes } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
const DRAWER_WIDTH = 220
@@ -82,6 +82,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: <Boxes size={20} />, type: 'route', path: '/mcp' },
]
const navItemSx = {
-57
View File
@@ -111,10 +111,6 @@ interface AISummarySettingsProps {
setEnableThinking: (val: boolean) => void
messageLimit: number
setMessageLimit: (val: number) => void
mcpEnabled: boolean
setMcpEnabled: (val: boolean) => void
mcpExposeMediaPaths: boolean
setMcpExposeMediaPaths: (val: boolean) => void
showMessage: (text: string, success: boolean) => void
}
@@ -137,10 +133,6 @@ function AISummarySettings({
setEnableThinking,
messageLimit,
setMessageLimit,
mcpEnabled,
setMcpEnabled,
mcpExposeMediaPaths,
setMcpExposeMediaPaths,
showMessage
}: AISummarySettingsProps) {
const [showApiKey, setShowApiKey] = useState(false)
@@ -758,55 +750,6 @@ function AISummarySettings({
</>
)}
<h3 className="section-title">MCP Server</h3>
<div className="settings-form" style={{ marginTop: '8px' }}>
<div className="form-group">
<label className="toggle-label">
<div className="toggle-header">
<span className="toggle-title"> MCP Server</span>
<span className="toggle-switch">
<input
type="checkbox"
checked={mcpEnabled}
onChange={(e) => setMcpEnabled(e.target.checked)}
/>
<span className="toggle-slider"></span>
</span>
</div>
</label>
<div className="toggle-description">
<p> Claude DesktopCodexCherry Studio MCP 宿 CipherTalk </p>
</div>
</div>
<div className="form-group">
<label className="toggle-label">
<div className="toggle-header">
<span className="toggle-title"></span>
<span className="toggle-switch">
<input
type="checkbox"
checked={mcpExposeMediaPaths}
onChange={(e) => setMcpExposeMediaPaths(e.target.checked)}
/>
<span className="toggle-slider"></span>
</span>
</div>
</label>
<div className="toggle-description">
<p> MCP `get_messages` </p>
</div>
</div>
<div className="form-group">
<label></label>
<input className="api-key-input" type="text" value="npm run build:mcp && node scripts/mcp-runner.js" readOnly />
<div className="form-hint">
`health_check``get_status``list_sessions``get_messages``list_contacts`
</div>
</div>
</div>
<div className="info-box-simple">
<p>💡 API </p>
</div>
+384
View File
@@ -0,0 +1,384 @@
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Box,
Button,
Card,
CardContent,
CardHeader,
Container,
Snackbar,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material'
import { Check, Copy, Save } from 'lucide-react'
import * as configService from '../services/config'
type ToastState = {
text: string
success: boolean
}
type McpLaunchConfig = {
command: string
args: string[]
cwd: string
mode: 'dev' | 'packaged'
}
function formatCommandPart(value: string) {
if (!value) return value
return /[\s"]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value
}
const textFieldSx = {
'& .MuiInputLabel-root': {
color: 'var(--text-secondary)',
},
'& .MuiInputLabel-root.Mui-focused': {
color: 'var(--primary)',
},
'& .MuiOutlinedInput-root': {
borderRadius: '14px',
color: 'var(--text-primary)',
backgroundColor: 'var(--bg-secondary)',
'& fieldset': {
borderColor: 'var(--border-color)',
},
'&:hover fieldset': {
borderColor: 'var(--primary)',
},
'&.Mui-focused fieldset': {
borderColor: 'var(--primary)',
},
},
'& .MuiInputBase-input': {
color: 'var(--text-primary)',
},
}
const switchSx = {
'& .MuiSwitch-switchBase.Mui-checked': {
color: 'var(--primary)',
},
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: 'var(--primary)',
},
'& .MuiSwitch-track': {
backgroundColor: 'var(--text-tertiary)',
},
}
const secondaryButtonSx = {
borderRadius: '999px',
minWidth: 120,
textTransform: 'none',
fontWeight: 600,
color: 'var(--text-primary)',
borderColor: 'var(--border-color)',
backgroundColor: 'var(--bg-secondary)',
'&:hover': {
borderColor: 'var(--primary)',
backgroundColor: 'var(--primary-light)',
},
}
function McpPage() {
const [mcpEnabled, setMcpEnabled] = useState(false)
const [mcpExposeMediaPaths, setMcpExposeMediaPaths] = useState(true)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState<ToastState | null>(null)
const [launchConfig, setLaunchConfig] = useState<McpLaunchConfig>({
command: 'npm',
args: ['run', 'mcp'],
cwd: 'D:/CipherTalk',
mode: 'dev',
})
useEffect(() => {
const load = async () => {
try {
const [enabled, exposeMediaPaths] = await Promise.all([
configService.getMcpEnabled(),
configService.getMcpExposeMediaPaths(),
])
setMcpEnabled(enabled)
setMcpExposeMediaPaths(exposeMediaPaths)
try {
const mcpLaunchConfig = await window.electronAPI.app.getMcpLaunchConfig()
if (mcpLaunchConfig?.command && Array.isArray(mcpLaunchConfig.args) && mcpLaunchConfig.cwd) {
setLaunchConfig(mcpLaunchConfig)
}
} catch (innerError) {
const message = String(innerError || '')
if (!message.includes("No handler registered for 'app:getMcpLaunchConfig'")) {
console.error('获取 MCP 启动配置失败:', innerError)
}
}
} catch (e) {
console.error('加载 MCP 配置失败:', e)
setToast({ text: '加载 MCP 配置失败', success: false })
} finally {
setLoading(false)
}
}
void load()
}, [])
const mcpRunCommand = useMemo(() => {
const parts = [launchConfig.command, ...launchConfig.args].map(formatCommandPart)
return parts.join(' ')
}, [launchConfig])
const mcpServerJsonTemplate = useMemo(() => JSON.stringify({
mcpServers: {
ciphertalk: {
command: launchConfig.command,
args: launchConfig.args,
cwd: launchConfig.cwd
}
}
}, null, 2), [launchConfig])
const handleSave = async () => {
setSaving(true)
try {
await Promise.all([
configService.setMcpEnabled(mcpEnabled),
configService.setMcpExposeMediaPaths(mcpExposeMediaPaths),
])
setToast({ text: 'MCP 配置已保存', success: true })
} catch (e) {
console.error('保存 MCP 配置失败:', e)
setToast({ text: '保存 MCP 配置失败', success: false })
} finally {
setSaving(false)
}
}
const copyText = async (text: string, successText: string) => {
try {
await navigator.clipboard.writeText(text)
setToast({ text: successText, success: true })
} catch (e) {
console.error('复制失败:', e)
setToast({ text: '复制失败,请手动复制', success: false })
}
}
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 } }}>
<Stack spacing={2.2}>
<Box sx={{ px: { xs: 0.5, md: 1 }, pt: 0.5 }}>
<Typography variant="h4" sx={{ fontSize: 30, fontWeight: 700, color: 'var(--text-primary)' }}>
MCP Server
</Typography>
<Typography sx={{ mt: 1, color: 'var(--text-secondary)' }}>
使 MCP `stdio` Claude DesktopCodexCherry Studio 宿
</Typography>
</Box>
<Card
sx={{
borderRadius: '26px',
border: '1px solid var(--border-color)',
bgcolor: 'var(--bg-secondary)',
boxShadow: 'none',
}}
>
<CardHeader
title="服务配置"
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.4}>
<Alert
severity="info"
variant="outlined"
sx={{
borderRadius: '18px',
bgcolor: 'var(--bg-primary)',
borderColor: 'var(--border-color)',
color: 'var(--text-primary)',
'& .MuiAlert-message': {
color: 'var(--text-primary)',
},
}}
>
`mcpEnabled` warning 宿 MCP
{launchConfig.mode === 'packaged'
? ' 当前展示的是打包版伴随启动器 `ciphertalk-mcp.cmd`。'
: ' 当前展示的是开发态入口 `npm run mcp`。'}
</Alert>
<Box
sx={{
p: 2,
borderRadius: '18px',
border: '1px solid var(--border-color)',
bgcolor: 'var(--bg-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box>
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}>MCP </Typography>
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
`health_check` / `get_status` 宿
</Typography>
</Box>
<Switch
checked={mcpEnabled}
onChange={(e) => setMcpEnabled(e.target.checked)}
disabled={loading || saving}
sx={switchSx}
/>
</Box>
<Box
sx={{
p: 2,
borderRadius: '18px',
border: '1px solid var(--border-color)',
bgcolor: 'var(--bg-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box>
<Typography sx={{ fontWeight: 600, color: 'var(--text-primary)' }}></Typography>
<Typography sx={{ mt: 0.5, fontSize: 13, color: 'var(--text-secondary)' }}>
`get_messages`
</Typography>
</Box>
<Switch
checked={mcpExposeMediaPaths}
onChange={(e) => setMcpExposeMediaPaths(e.target.checked)}
disabled={loading || saving}
sx={switchSx}
/>
</Box>
<Box>
<Typography sx={{ mb: 1, fontWeight: 600, color: 'var(--text-primary)' }}></Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2}>
<TextField
fullWidth
value={mcpRunCommand}
InputProps={{ readOnly: true }}
sx={textFieldSx}
/>
<Button
variant="outlined"
startIcon={<Copy size={16} />}
onClick={() => copyText(mcpRunCommand, 'MCP 启动命令已复制')}
sx={secondaryButtonSx}
>
</Button>
</Stack>
</Box>
<Box>
<Typography sx={{ mb: 1, fontWeight: 600, color: 'var(--text-primary)' }}>
mcpServers
</Typography>
<TextField
fullWidth
multiline
minRows={9}
value={mcpServerJsonTemplate}
InputProps={{ readOnly: true }}
sx={{
...textFieldSx,
'& .MuiOutlinedInput-root': {
borderRadius: '14px',
color: 'var(--text-primary)',
backgroundColor: 'var(--bg-secondary)',
fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
},
}}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2} sx={{ mt: 1.2 }}>
<Button
variant="outlined"
startIcon={<Copy size={16} />}
onClick={() => copyText(mcpServerJsonTemplate, 'mcpServers 配置已复制')}
sx={secondaryButtonSx}
>
</Button>
<Typography sx={{ alignSelf: 'center', fontSize: 13, color: 'var(--text-secondary)' }}>
{launchConfig.mode === 'packaged'
? '`cwd` 已指向安装目录,宿主通常无需额外包一层 shell。'
: '`cwd` 已自动使用当前仓库目录,通常无需修改。'}
</Typography>
</Stack>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.2} justifyContent="space-between" alignItems={{ xs: 'stretch', sm: 'center' }}>
<Typography sx={{ fontSize: 13, color: 'var(--text-secondary)' }}>
v1 `health_check``get_status``list_sessions``get_messages`
</Typography>
<Button
variant="contained"
startIcon={<Save size={16} />}
onClick={handleSave}
disabled={loading || saving}
sx={{
borderRadius: '999px',
px: 2.6,
textTransform: 'none',
fontWeight: 700,
background: 'var(--primary-gradient)',
'&:hover': {
background: 'var(--primary-gradient)',
filter: 'brightness(0.98)',
},
}}
>
{saving ? '保存中...' : '保存配置'}
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
</Stack>
</Container>
<Snackbar
open={!!toast}
autoHideDuration={2400}
onClose={() => setToast(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
icon={toast?.success ? <Check size={16} /> : undefined}
severity={toast?.success ? 'success' : 'error'}
variant="filled"
onClose={() => setToast(null)}
sx={{
borderRadius: '12px',
color: '#fff',
bgcolor: toast?.success ? 'var(--primary)' : 'var(--danger)',
}}
>
{toast?.text}
</Alert>
</Snackbar>
</Box>
)
}
export default McpPage
-19
View File
@@ -140,8 +140,6 @@ function SettingsPage() {
const [aiCustomSystemPrompt, setAiCustomSystemPromptState] = useState<string>('')
const [aiEnableThinking, setAiEnableThinkingState] = useState<boolean>(true)
const [aiMessageLimit, setAiMessageLimitState] = useState<number>(3000)
const [mcpEnabled, setMcpEnabledState] = useState<boolean>(false)
const [mcpExposeMediaPaths, setMcpExposeMediaPathsState] = useState<boolean>(true)
// 日志相关状态
const [logFiles, setLogFiles] = useState<Array<{ name: string; size: number; mtime: Date }>>([])
@@ -221,8 +219,6 @@ function SettingsPage() {
const savedAiCustomSystemPrompt = await configService.getAiCustomSystemPrompt()
const savedAiEnableThinking = await configService.getAiEnableThinking()
const savedAiMessageLimit = await configService.getAiMessageLimit()
const savedMcpEnabled = await configService.getMcpEnabled()
const savedMcpExposeMediaPaths = await configService.getMcpExposeMediaPaths()
setAiProviderState(savedAiProvider)
setAiApiKeyState(savedAiApiKey)
@@ -233,8 +229,6 @@ function SettingsPage() {
setAiCustomSystemPromptState(savedAiCustomSystemPrompt)
setAiEnableThinkingState(savedAiEnableThinking)
setAiMessageLimitState(savedAiMessageLimit)
setMcpEnabledState(savedMcpEnabled)
setMcpExposeMediaPathsState(savedMcpExposeMediaPaths)
// 加载关闭行为配置
const savedCloseToTray = await configService.getCloseToTray()
@@ -268,8 +262,6 @@ function SettingsPage() {
aiCustomSystemPrompt: savedAiCustomSystemPrompt,
aiEnableThinking: savedAiEnableThinking,
aiMessageLimit: savedAiMessageLimit,
mcpEnabled: savedMcpEnabled,
mcpExposeMediaPaths: savedMcpExposeMediaPaths,
closeToTray: savedCloseToTray
})
@@ -318,8 +310,6 @@ function SettingsPage() {
aiCustomSystemPrompt,
aiEnableThinking,
aiMessageLimit,
mcpEnabled,
mcpExposeMediaPaths,
closeToTray
}
@@ -333,7 +323,6 @@ function SettingsPage() {
quoteStyle, exportDefaultDateRange, exportDefaultAvatars,
aiProvider, aiApiKey, aiModel, aiDefaultTimeRange, aiSummaryDetail,
aiSystemPromptPreset, aiCustomSystemPrompt, aiEnableThinking, aiMessageLimit,
mcpEnabled, mcpExposeMediaPaths,
closeToTray, initialConfig
])
@@ -859,8 +848,6 @@ function SettingsPage() {
await configService.setAiCustomSystemPrompt(aiCustomSystemPrompt)
await configService.setAiEnableThinking(aiEnableThinking)
await configService.setAiMessageLimit(aiMessageLimit)
await configService.setMcpEnabled(mcpEnabled)
await configService.setMcpExposeMediaPaths(mcpExposeMediaPaths)
// 保存关闭行为配置
await configService.setCloseToTray(closeToTray)
@@ -900,8 +887,6 @@ function SettingsPage() {
aiCustomSystemPrompt,
aiEnableThinking,
aiMessageLimit,
mcpEnabled,
mcpExposeMediaPaths,
closeToTray
})
setHasUnsavedChanges(false)
@@ -2802,10 +2787,6 @@ function SettingsPage() {
setEnableThinking={setAiEnableThinkingState}
messageLimit={aiMessageLimit}
setMessageLimit={setAiMessageLimitState}
mcpEnabled={mcpEnabled}
setMcpEnabled={setMcpEnabledState}
mcpExposeMediaPaths={mcpExposeMediaPaths}
setMcpExposeMediaPaths={setMcpExposeMediaPathsState}
showMessage={showMessage}
/>
)}
+6
View File
@@ -75,6 +75,12 @@ export interface ElectronAPI {
app: {
getDownloadsPath: () => Promise<string>
getVersion: () => Promise<string>
getMcpLaunchConfig: () => Promise<{
command: string
args: string[]
cwd: string
mode: 'dev' | 'packaged'
} | null>
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
downloadAndInstall: () => Promise<void>
getStartupDbConnected?: () => Promise<boolean>