mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-17 01:40:22 +08:00
feat: 新增 MCP 聊天导出自动化并升级到 3.0.0
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
**一款现代化的微信聊天记录查看与分析工具**
|
||||
|
||||
[](LICENSE)
|
||||
[](package.json)
|
||||
[](package.json)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
@@ -32,6 +32,19 @@ export async function executeMcpTool(
|
||||
payload
|
||||
}
|
||||
}
|
||||
case 'export_chat': {
|
||||
const payload = await readService.exportChat(args as any, reporter)
|
||||
return {
|
||||
summary: payload.success
|
||||
? `Exported chat for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
|
||||
: payload.success === false
|
||||
? `Failed to export chat for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
|
||||
: payload.canExport
|
||||
? `Prepared export for ${payload.resolvedSession?.displayName || payload.resolvedSession?.sessionId || 'target session'}.`
|
||||
: 'Export request needs more information.',
|
||||
payload
|
||||
}
|
||||
}
|
||||
case 'get_global_statistics': {
|
||||
const payload = await readService.getGlobalStatistics(args as any)
|
||||
return { summary: 'Loaded global statistics.', payload }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { accessSync, constants, existsSync, mkdirSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { z } from 'zod'
|
||||
import { analyticsService } from '../analyticsService'
|
||||
import { chatService, type ChatSession, type ContactInfo, type Message } from '../chatService'
|
||||
import { ConfigService } from '../config'
|
||||
import { exportService, type ExportOptions as ExportServiceOptions } from '../exportService'
|
||||
import { imageDecryptService } from '../imageDecryptService'
|
||||
import { videoService } from '../videoService'
|
||||
import { McpToolError } from './result'
|
||||
@@ -15,6 +16,11 @@ import {
|
||||
type McpContactKind,
|
||||
type McpContactsPayload,
|
||||
type McpCursor,
|
||||
type McpExportChatPayload,
|
||||
type McpExportDateRange,
|
||||
type McpExportFormat,
|
||||
type McpExportMediaOptions,
|
||||
type McpExportMissingField,
|
||||
type McpGlobalStatisticsPayload,
|
||||
type McpContactRankingItem,
|
||||
type McpContactRankingsPayload,
|
||||
@@ -57,6 +63,33 @@ const resolveSessionArgsSchema = z.object({
|
||||
limit: z.number().int().positive().optional()
|
||||
})
|
||||
|
||||
const exportChatArgsSchema = z.object({
|
||||
sessionId: z.string().trim().min(1).optional(),
|
||||
query: z.string().trim().min(1).optional(),
|
||||
format: z.enum(['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']).optional(),
|
||||
dateRange: z.object({
|
||||
start: z.number().int().positive(),
|
||||
end: z.number().int().positive()
|
||||
}).optional(),
|
||||
mediaOptions: z.object({
|
||||
exportAvatars: z.boolean().optional(),
|
||||
exportImages: z.boolean().optional(),
|
||||
exportVideos: z.boolean().optional(),
|
||||
exportEmojis: z.boolean().optional(),
|
||||
exportVoices: z.boolean().optional()
|
||||
}).optional(),
|
||||
outputDir: z.string().trim().min(1).optional(),
|
||||
validateOnly: z.boolean().optional()
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.sessionId && !value.query) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['sessionId'],
|
||||
message: 'sessionId or query is required'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const getMessagesArgsSchema = z.object({
|
||||
sessionId: z.string().trim().min(1),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
@@ -126,6 +159,7 @@ const getSessionContextArgsSchema = z.object({
|
||||
|
||||
type ListSessionsArgs = z.infer<typeof listSessionsArgsSchema>
|
||||
type ResolveSessionArgs = z.infer<typeof resolveSessionArgsSchema>
|
||||
type ExportChatArgs = z.infer<typeof exportChatArgsSchema>
|
||||
type GetMessagesArgs = z.infer<typeof getMessagesArgsSchema>
|
||||
type ListContactsArgs = z.infer<typeof listContactsArgsSchema>
|
||||
type SearchMessagesArgs = z.infer<typeof searchMessagesArgsSchema>
|
||||
@@ -160,6 +194,8 @@ type McpStreamReporter = {
|
||||
partial?: <K extends keyof McpStreamPartialPayloadMap>(toolName: K, payload: McpStreamPartialPayloadMap[K]) => void | Promise<void>
|
||||
}
|
||||
|
||||
const SUPPORTED_EXPORT_FORMATS: McpExportFormat[] = ['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']
|
||||
|
||||
function toTimestampMs(value?: number | null): number {
|
||||
if (!value || !Number.isFinite(value) || value <= 0) return 0
|
||||
return value < 1_000_000_000_000 ? value * 1000 : value
|
||||
@@ -631,6 +667,134 @@ function buildSearchSessionSummaries(hits: McpSearchHit[]): McpSearchMessagesPay
|
||||
.sort((a, b) => b.hitCount - a.hitCount || b.topScore - a.topScore)
|
||||
}
|
||||
|
||||
function getDefaultExportPath(): string | null {
|
||||
const config = new ConfigService()
|
||||
try {
|
||||
const exportPath = String(config.get('exportPath') || '').trim()
|
||||
return exportPath || null
|
||||
} finally {
|
||||
config.close()
|
||||
}
|
||||
}
|
||||
|
||||
function isWritableDirectory(dir: string): boolean {
|
||||
try {
|
||||
if (!dir) return false
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
accessSync(dir, constants.W_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isCompleteMediaOptions(
|
||||
mediaOptions?: ExportChatArgs['mediaOptions']
|
||||
): mediaOptions is McpExportMediaOptions {
|
||||
return Boolean(
|
||||
mediaOptions
|
||||
&& typeof mediaOptions.exportAvatars === 'boolean'
|
||||
&& typeof mediaOptions.exportImages === 'boolean'
|
||||
&& typeof mediaOptions.exportVideos === 'boolean'
|
||||
&& typeof mediaOptions.exportEmojis === 'boolean'
|
||||
&& typeof mediaOptions.exportVoices === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function getNextExportQuestion(missingFields: McpExportMissingField[]): string | undefined {
|
||||
if (missingFields.includes('session')) {
|
||||
return '请先确认要导出哪个会话,可以提供 sessionId 或更具体的联系人线索。'
|
||||
}
|
||||
if (missingFields.includes('dateRange')) {
|
||||
return '请补充导出的时间范围,至少需要开始时间和结束时间。'
|
||||
}
|
||||
if (missingFields.includes('format')) {
|
||||
return '请确认导出格式,仅支持 chatlab、chatlab-jsonl、json、excel、html。'
|
||||
}
|
||||
if (missingFields.includes('mediaOptions')) {
|
||||
return '请明确是否导出头像、图片、视频、表情、语音。'
|
||||
}
|
||||
if (missingFields.includes('outputDir')) {
|
||||
return '默认导出目录不可用,请提供一个可写入的导出目录。'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildExportFollowUpQuestions(missingFields: McpExportMissingField[]): Array<{
|
||||
field: McpExportMissingField
|
||||
question: string
|
||||
}> {
|
||||
const questions: Array<{ field: McpExportMissingField; question: string }> = []
|
||||
|
||||
for (const field of missingFields) {
|
||||
if (field === 'session') {
|
||||
questions.push({
|
||||
field,
|
||||
question: '你要导出哪个会话?可以给我更具体的联系人、备注名或 sessionId。'
|
||||
})
|
||||
} else if (field === 'dateRange') {
|
||||
questions.push({
|
||||
field,
|
||||
question: '这次导出的时间范围是什么?请给我开始时间和结束时间。'
|
||||
})
|
||||
} else if (field === 'format') {
|
||||
questions.push({
|
||||
field,
|
||||
question: '你要导出成哪种格式?目前支持 chatlab、chatlab-jsonl、json、excel、html。'
|
||||
})
|
||||
} else if (field === 'mediaOptions') {
|
||||
questions.push({
|
||||
field,
|
||||
question: '媒体要怎么导?请分别确认是否包含头像、图片、视频、表情、语音。'
|
||||
})
|
||||
} else if (field === 'outputDir') {
|
||||
questions.push({
|
||||
field,
|
||||
question: '默认导出目录不可用,请给我一个可写入的导出目录。'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
function buildPredictedExportPath(
|
||||
outputDir: string,
|
||||
resolvedSession: Pick<McpResolvedSessionCandidate, 'displayName'>,
|
||||
format: McpExportFormat,
|
||||
mediaOptions: McpExportMediaOptions
|
||||
): string {
|
||||
const safeName = resolvedSession.displayName.replace(/[<>:"/\\|?*]/g, '_').replace(/\.+$/, '').trim() || 'export'
|
||||
const ext = format === 'chatlab-jsonl'
|
||||
? '.jsonl'
|
||||
: format === 'excel'
|
||||
? '.xlsx'
|
||||
: format === 'html'
|
||||
? '.html'
|
||||
: '.json'
|
||||
const hasMedia = mediaOptions.exportImages || mediaOptions.exportVideos || mediaOptions.exportEmojis || mediaOptions.exportVoices
|
||||
const sessionOutputDir = hasMedia ? join(outputDir, safeName) : outputDir
|
||||
return join(sessionOutputDir, `${safeName}${ext}`)
|
||||
}
|
||||
|
||||
function toExportServiceOptions(
|
||||
format: McpExportFormat,
|
||||
dateRange: McpExportDateRange,
|
||||
mediaOptions: McpExportMediaOptions
|
||||
): ExportServiceOptions {
|
||||
return {
|
||||
format,
|
||||
dateRange,
|
||||
exportAvatars: mediaOptions.exportAvatars,
|
||||
exportImages: mediaOptions.exportImages,
|
||||
exportVideos: mediaOptions.exportVideos,
|
||||
exportEmojis: mediaOptions.exportEmojis,
|
||||
exportVoices: mediaOptions.exportVoices
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSessionRefStrict(
|
||||
rawInput: string,
|
||||
sessions: McpSessionItem[],
|
||||
@@ -1060,6 +1224,160 @@ export class McpReadService {
|
||||
return payload
|
||||
}
|
||||
|
||||
async exportChat(rawArgs: ExportChatArgs, reporter?: McpStreamReporter): Promise<McpExportChatPayload> {
|
||||
const args = exportChatArgsSchema.safeParse(rawArgs)
|
||||
if (!args.success) {
|
||||
throw new McpToolError('BAD_REQUEST', 'Invalid export_chat arguments.', args.error.message)
|
||||
}
|
||||
|
||||
const data = args.data
|
||||
const validateOnly = Boolean(data.validateOnly)
|
||||
await reportProgress(reporter, {
|
||||
stage: 'validating_export_request',
|
||||
message: 'Validating export request.'
|
||||
})
|
||||
|
||||
const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([
|
||||
getSessionCatalog(),
|
||||
getContactCatalog()
|
||||
])
|
||||
|
||||
let resolvedSession: McpResolvedSessionCandidate | undefined
|
||||
let candidates: McpResolvedSessionCandidate[] = []
|
||||
|
||||
if (data.sessionId || data.query) {
|
||||
const query = data.sessionId || data.query || ''
|
||||
const matchedCandidates = findSessionCandidates(query, sessions, contacts).slice(0, 5)
|
||||
candidates = matchedCandidates.map((candidate) => toResolvedCandidate(candidate, query))
|
||||
|
||||
try {
|
||||
const resolved = await resolveSessionRefStrictWithProgress(query, sessions, sessionMap, contacts, contactMap, reporter)
|
||||
const matched = matchedCandidates.find((candidate) => candidate.entry.session.sessionId === resolved.sessionId)
|
||||
resolvedSession = matched ? toResolvedCandidate(matched, query) : {
|
||||
...resolved,
|
||||
score: 1000,
|
||||
confidence: 'high',
|
||||
aliases: [resolved.displayName, resolved.sessionId],
|
||||
evidence: ['Resolved directly from the provided session clue.']
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof McpToolError) || (error.code !== 'BAD_REQUEST' && error.code !== 'SESSION_NOT_FOUND')) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missingFields: McpExportMissingField[] = []
|
||||
if (!resolvedSession) {
|
||||
missingFields.push('session')
|
||||
}
|
||||
if (!data.dateRange || !data.dateRange.start || !data.dateRange.end) {
|
||||
missingFields.push('dateRange')
|
||||
} else if (data.dateRange.start > data.dateRange.end) {
|
||||
throw new McpToolError('BAD_REQUEST', 'Invalid export date range.', 'dateRange.start must be earlier than or equal to dateRange.end.')
|
||||
}
|
||||
if (!data.format) {
|
||||
missingFields.push('format')
|
||||
} else if (!SUPPORTED_EXPORT_FORMATS.includes(data.format)) {
|
||||
throw new McpToolError('BAD_REQUEST', 'Unsupported export format.', `Only ${SUPPORTED_EXPORT_FORMATS.join(', ')} are supported.`)
|
||||
}
|
||||
if (!isCompleteMediaOptions(data.mediaOptions)) {
|
||||
missingFields.push('mediaOptions')
|
||||
}
|
||||
|
||||
const requestedOutputDir = String(data.outputDir || '').trim()
|
||||
const outputDir = requestedOutputDir || getDefaultExportPath() || ''
|
||||
if (!outputDir || !isWritableDirectory(outputDir)) {
|
||||
missingFields.push('outputDir')
|
||||
}
|
||||
|
||||
const nextQuestion = getNextExportQuestion(missingFields)
|
||||
const followUpQuestions = buildExportFollowUpQuestions(missingFields)
|
||||
const payload: McpExportChatPayload = {
|
||||
canExport: missingFields.length === 0,
|
||||
validateOnly,
|
||||
missingFields,
|
||||
nextQuestion,
|
||||
followUpQuestions,
|
||||
resolvedSession,
|
||||
candidates,
|
||||
outputDir: outputDir || undefined,
|
||||
format: data.format,
|
||||
dateRange: data.dateRange,
|
||||
mediaOptions: isCompleteMediaOptions(data.mediaOptions) ? data.mediaOptions : undefined,
|
||||
message: missingFields.length === 0
|
||||
? validateOnly
|
||||
? 'Export request is complete and ready to run.'
|
||||
: 'Export request validated and ready to execute.'
|
||||
: 'Export request is incomplete and needs more information.'
|
||||
}
|
||||
|
||||
await reportPartial(reporter, 'export_chat', payload)
|
||||
|
||||
if (missingFields.length > 0 || validateOnly) {
|
||||
return payload
|
||||
}
|
||||
|
||||
await reportProgress(reporter, {
|
||||
stage: 'preparing_export',
|
||||
message: `Preparing export for ${resolvedSession!.displayName}.`,
|
||||
candidates: [{ sessionId: resolvedSession!.sessionId, displayName: resolvedSession!.displayName, kind: resolvedSession!.kind }],
|
||||
candidateCount: 1
|
||||
})
|
||||
|
||||
const exportOptions = toExportServiceOptions(
|
||||
data.format!,
|
||||
data.dateRange!,
|
||||
data.mediaOptions as McpExportMediaOptions
|
||||
)
|
||||
|
||||
const predictedOutputPath = buildPredictedExportPath(
|
||||
outputDir,
|
||||
resolvedSession!,
|
||||
data.format!,
|
||||
data.mediaOptions as McpExportMediaOptions
|
||||
)
|
||||
|
||||
const result = await exportService.exportSessions(
|
||||
[resolvedSession!.sessionId],
|
||||
outputDir,
|
||||
exportOptions,
|
||||
(progress) => {
|
||||
const stage = progress.phase === 'writing'
|
||||
? 'writing'
|
||||
: progress.phase === 'exporting'
|
||||
? 'exporting'
|
||||
: progress.phase === 'complete'
|
||||
? 'completed'
|
||||
: 'preparing_export'
|
||||
|
||||
void reportProgress(reporter, {
|
||||
stage,
|
||||
message: progress.detail || progress.phase,
|
||||
sessionsScanned: progress.current,
|
||||
candidates: [{ sessionId: resolvedSession!.sessionId, displayName: resolvedSession!.displayName, kind: resolvedSession!.kind }],
|
||||
candidateCount: 1
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const completedPayload: McpExportChatPayload = {
|
||||
...payload,
|
||||
canExport: true,
|
||||
success: result.success,
|
||||
successCount: result.successCount,
|
||||
failCount: result.failCount,
|
||||
error: result.error,
|
||||
outputPath: predictedOutputPath,
|
||||
message: result.success
|
||||
? `Exported chat for ${resolvedSession!.displayName}.`
|
||||
: `Failed to export chat for ${resolvedSession!.displayName}.`
|
||||
}
|
||||
|
||||
await reportPartial(reporter, 'export_chat', completedPayload)
|
||||
return completedPayload
|
||||
}
|
||||
|
||||
async listSessions(rawArgs: ListSessionsArgs, reporter?: McpStreamReporter): Promise<McpSessionsPayload> {
|
||||
const args = listSessionsArgsSchema.safeParse(rawArgs)
|
||||
if (!args.success) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
McpActivityDistributionPayload,
|
||||
McpContactRankingsPayload,
|
||||
McpContactsPayload,
|
||||
McpExportChatPayload,
|
||||
McpGlobalStatisticsPayload,
|
||||
McpHealthPayload,
|
||||
McpMessagesPayload,
|
||||
@@ -270,6 +271,10 @@ export class McpReadService {
|
||||
return this.callProxy<McpResolveSessionPayload>('resolve_session', rawArgs)
|
||||
}
|
||||
|
||||
async exportChat(rawArgs: Record<string, unknown>): Promise<McpExportChatPayload> {
|
||||
return this.callProxy<McpExportChatPayload>('export_chat', rawArgs)
|
||||
}
|
||||
|
||||
async listSessions(rawArgs: Record<string, unknown>): Promise<McpSessionsPayload> {
|
||||
return this.callProxy<McpSessionsPayload>('list_sessions', rawArgs)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,36 @@ export function registerCipherTalkMcpTools(server: any) {
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('export_chat', {
|
||||
title: 'Export Chat',
|
||||
description: 'Validate and export chat history for one resolved session. This tool strictly checks target session, date range, export format, media selections, and output directory before exporting.',
|
||||
inputSchema: {
|
||||
sessionId: z.string().trim().min(1).optional().describe('Resolved sessionId when already known.'),
|
||||
query: z.string().trim().min(1).optional().describe('Fuzzy session clue when sessionId is not yet known.'),
|
||||
format: z.enum(['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']).optional().describe('Export format.'),
|
||||
dateRange: z.object({
|
||||
start: z.number().int().positive(),
|
||||
end: z.number().int().positive()
|
||||
}).optional().describe('Required export time range in seconds or milliseconds.'),
|
||||
mediaOptions: z.object({
|
||||
exportAvatars: z.boolean().optional(),
|
||||
exportImages: z.boolean().optional(),
|
||||
exportVideos: z.boolean().optional(),
|
||||
exportEmojis: z.boolean().optional(),
|
||||
exportVoices: z.boolean().optional()
|
||||
}).optional().describe('Required explicit media export selections.'),
|
||||
outputDir: z.string().trim().min(1).optional().describe('Optional output directory. If omitted, the configured default export path will be used when available.'),
|
||||
validateOnly: z.boolean().optional().describe('When true, only validate completeness and return missing fields without exporting.')
|
||||
}
|
||||
}, async (args: unknown) => {
|
||||
try {
|
||||
const payload = await readService.exportChat((args || {}) as any)
|
||||
return createToolSuccess(payload.message, payload)
|
||||
} catch (error) {
|
||||
return createToolError(error)
|
||||
}
|
||||
})
|
||||
|
||||
server.registerTool('get_global_statistics', {
|
||||
title: 'Get Global Statistics',
|
||||
description: 'Return global private-chat statistics for agent-side analysis.',
|
||||
|
||||
@@ -2,6 +2,7 @@ export const MCP_TOOL_NAMES = [
|
||||
'health_check',
|
||||
'get_status',
|
||||
'resolve_session',
|
||||
'export_chat',
|
||||
'list_sessions',
|
||||
'get_messages',
|
||||
'list_contacts',
|
||||
@@ -56,7 +57,11 @@ export type McpStreamProgressStage =
|
||||
| 'searching_contacts'
|
||||
| 'searching_sessions'
|
||||
| 'resolving_candidates'
|
||||
| 'validating_export_request'
|
||||
| 'preparing_export'
|
||||
| 'scanning_messages'
|
||||
| 'exporting'
|
||||
| 'writing'
|
||||
| 'streaming_hits'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
@@ -150,6 +155,51 @@ export interface McpResolveSessionPayload {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type McpExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'excel' | 'html'
|
||||
|
||||
export interface McpExportMediaOptions {
|
||||
exportAvatars: boolean
|
||||
exportImages: boolean
|
||||
exportVideos: boolean
|
||||
exportEmojis: boolean
|
||||
exportVoices: boolean
|
||||
}
|
||||
|
||||
export type McpExportMissingField =
|
||||
| 'session'
|
||||
| 'dateRange'
|
||||
| 'format'
|
||||
| 'mediaOptions'
|
||||
| 'outputDir'
|
||||
|
||||
export interface McpExportDateRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface McpExportChatPayload {
|
||||
canExport: boolean
|
||||
validateOnly: boolean
|
||||
missingFields: McpExportMissingField[]
|
||||
nextQuestion?: string
|
||||
followUpQuestions?: Array<{
|
||||
field: McpExportMissingField
|
||||
question: string
|
||||
}>
|
||||
resolvedSession?: McpResolvedSessionCandidate
|
||||
candidates?: McpResolvedSessionCandidate[]
|
||||
outputDir?: string
|
||||
outputPath?: string
|
||||
format?: McpExportFormat
|
||||
dateRange?: McpExportDateRange
|
||||
mediaOptions?: McpExportMediaOptions
|
||||
success?: boolean
|
||||
successCount?: number
|
||||
failCount?: number
|
||||
error?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface McpContactItem {
|
||||
contactId: string
|
||||
sessionId?: string
|
||||
@@ -309,6 +359,7 @@ export interface McpStreamProgressPayload {
|
||||
|
||||
export interface McpStreamPartialPayloadMap {
|
||||
resolve_session: Partial<McpResolveSessionPayload>
|
||||
export_chat: Partial<McpExportChatPayload>
|
||||
list_sessions: Partial<McpSessionsPayload>
|
||||
list_contacts: Partial<McpContactsPayload>
|
||||
get_messages: Partial<McpMessagesPayload>
|
||||
@@ -317,6 +368,7 @@ export interface McpStreamPartialPayloadMap {
|
||||
}
|
||||
|
||||
export type McpStreamPartialPayload =
|
||||
| McpStreamPartialPayloadMap['export_chat']
|
||||
| McpStreamPartialPayloadMap['list_sessions']
|
||||
| McpStreamPartialPayloadMap['list_contacts']
|
||||
| McpStreamPartialPayloadMap['get_messages']
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "2.3.5",
|
||||
"version": "3.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ciphertalk",
|
||||
"version": "2.3.5",
|
||||
"version": "3.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "2.3.5",
|
||||
"version": "3.0.0",
|
||||
"description": "密语 - 微信聊天记录查看工具",
|
||||
"author": "ILoveBingLu",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
|
||||
@@ -66,6 +66,39 @@ After each meaningful exploration round, produce a very short battle report for
|
||||
|
||||
Keep it short. It should help trace the reasoning, not overshadow the answer.
|
||||
|
||||
## Export workflow
|
||||
|
||||
When the user asks to export chat history:
|
||||
|
||||
1. Check whether the request already includes:
|
||||
- target session
|
||||
- time range
|
||||
- export format
|
||||
- media selections
|
||||
2. If the target is fuzzy, resolve it first with `resolve_session`.
|
||||
3. If the target is still ambiguous, keep narrowing and do not export yet.
|
||||
4. Use `export_chat(validateOnly=true)` to audit whether the request is complete.
|
||||
5. If `missingFields` is non-empty, prefer `followUpQuestions`; otherwise fall back to `nextQuestion`.
|
||||
6. Ask follow-up questions until the missing fields are all resolved.
|
||||
7. Prefer the configured default export directory when it exists and is writable.
|
||||
8. If the default export directory is unavailable, ask the user for an output directory.
|
||||
9. Only call `export_chat` without `validateOnly` after the request is complete.
|
||||
|
||||
When asking follow-up questions for export:
|
||||
|
||||
- ask only for missing fields
|
||||
- do not ask again for fields the user already confirmed
|
||||
- treat media selections as required and explicit
|
||||
- do not silently assume a time range
|
||||
|
||||
After export finishes, summarize:
|
||||
|
||||
- which session was exported
|
||||
- the time range
|
||||
- the format
|
||||
- which media were included
|
||||
- where the files were written
|
||||
|
||||
## Never do this
|
||||
|
||||
- Do not conclude “没有数据” after a single failed query.
|
||||
@@ -73,7 +106,10 @@ Keep it short. It should help trace the reasoning, not overshadow the answer.
|
||||
- Do not ignore `hint` or candidate summaries returned by MCP.
|
||||
- Do not ignore `evidence` on resolved candidates or `sessionSummaries` on search results.
|
||||
- Do not lock onto a candidate while ambiguity is still obvious.
|
||||
- Do not start exporting before target session, time range, format, and media selections are all confirmed.
|
||||
- Do not quietly choose a time range or media mix on the user’s behalf.
|
||||
|
||||
## References
|
||||
|
||||
- Read [references/queries.md](references/queries.md) when you need concrete fuzzy-query playbooks, fallback chains, or battle-report examples.
|
||||
- Read [references/export.md](references/export.md) when the user asks to export chat history.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# CipherTalk MCP Export Playbook
|
||||
|
||||
## Goal
|
||||
|
||||
Turn vague export requests into a complete, executable export plan.
|
||||
|
||||
## Required fields before exporting
|
||||
|
||||
Do not export until all of these are known:
|
||||
|
||||
- target session
|
||||
- time range
|
||||
- export format
|
||||
- media selections
|
||||
|
||||
Output directory may be omitted only if the configured default export directory is available and writable.
|
||||
|
||||
## Export routing
|
||||
|
||||
### 1. User asks to export chat history with incomplete info
|
||||
|
||||
Example:
|
||||
|
||||
- “导出聊天记录”
|
||||
- “把那个人的聊天导出来”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Resolve the session if needed with `resolve_session`
|
||||
2. Call `export_chat(validateOnly=true)`
|
||||
3. Read `missingFields`
|
||||
4. Prefer `followUpQuestions`; use `nextQuestion` only as fallback
|
||||
5. Ask only for the missing fields
|
||||
6. Repeat `validateOnly` until `canExport=true`
|
||||
7. Call `export_chat(validateOnly=false)`
|
||||
|
||||
Battle report:
|
||||
|
||||
- “战报:导出条件还没齐,先把缺项问全。”
|
||||
|
||||
### 2. User gives target and format but no time range
|
||||
|
||||
Example:
|
||||
|
||||
- “导出这个会话为 html”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Confirm the target session
|
||||
2. Run `export_chat(validateOnly=true)`
|
||||
3. Ask for time range
|
||||
4. Ask for media selections if still missing
|
||||
5. Export only after validation passes
|
||||
|
||||
### 3. User gives almost everything
|
||||
|
||||
Example:
|
||||
|
||||
- “导出最近三个月的聊天记录为 html,只要图片和视频”
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Resolve the target session if needed
|
||||
2. Run `export_chat(validateOnly=true)`
|
||||
3. If only `outputDir` is missing, prefer the configured default export path
|
||||
4. If validation passes, export directly
|
||||
|
||||
Battle report:
|
||||
|
||||
- “战报:导出参数基本齐了,只差最后确认落盘位置。”
|
||||
|
||||
## How to ask follow-up questions
|
||||
|
||||
Ask in this priority order:
|
||||
|
||||
1. target session
|
||||
2. time range
|
||||
3. format
|
||||
4. media selections
|
||||
5. output directory only if default path is unavailable
|
||||
|
||||
When asking about media selections, be explicit:
|
||||
|
||||
- avatars
|
||||
- images
|
||||
- videos
|
||||
- emojis
|
||||
- voices
|
||||
|
||||
Do not accept vague phrasing like “带媒体” without clarifying the exact set.
|
||||
|
||||
## Answer style after export
|
||||
|
||||
Keep the export completion summary short and operational:
|
||||
|
||||
- exported session
|
||||
- time range
|
||||
- format
|
||||
- included media
|
||||
- output path
|
||||
|
||||
## Local helper
|
||||
|
||||
If you want a local dry-run outside MCP, use `scripts/validate-export-request.cjs` to sanity-check a request payload before wiring it into tool calls.
|
||||
@@ -0,0 +1,61 @@
|
||||
const fs = require('fs')
|
||||
|
||||
function readInput() {
|
||||
const chunks = []
|
||||
const fd = 0
|
||||
try {
|
||||
const stat = fs.fstatSync(fd)
|
||||
if (stat.size === 0 && process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return fs.readFileSync(fd, 'utf8').trim() || null
|
||||
}
|
||||
|
||||
function validate(payload) {
|
||||
const missingFields = []
|
||||
|
||||
if (!payload.sessionId && !payload.query) missingFields.push('session')
|
||||
|
||||
if (!payload.dateRange || !payload.dateRange.start || !payload.dateRange.end) {
|
||||
missingFields.push('dateRange')
|
||||
}
|
||||
|
||||
if (!payload.format) {
|
||||
missingFields.push('format')
|
||||
}
|
||||
|
||||
const media = payload.mediaOptions
|
||||
const completeMedia = media
|
||||
&& typeof media.exportAvatars === 'boolean'
|
||||
&& typeof media.exportImages === 'boolean'
|
||||
&& typeof media.exportVideos === 'boolean'
|
||||
&& typeof media.exportEmojis === 'boolean'
|
||||
&& typeof media.exportVoices === 'boolean'
|
||||
|
||||
if (!completeMedia) {
|
||||
missingFields.push('mediaOptions')
|
||||
}
|
||||
|
||||
return {
|
||||
canExport: missingFields.length === 0,
|
||||
missingFields
|
||||
}
|
||||
}
|
||||
|
||||
const raw = readInput()
|
||||
if (!raw) {
|
||||
console.error('Provide a JSON payload via stdin.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
console.error(`Invalid JSON: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(validate(payload), null, 2)}\n`)
|
||||
Reference in New Issue
Block a user