diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 629d7a2..1d23e01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,6 +134,11 @@ jobs: Write-Error "latest.yml not found" exit 1 } + $sizeLines = @(Select-String -Path "release/latest.yml" -Pattern '^\s+size:\s+\d+\s*$') + if ($sizeLines.Count -ne 1) { + Write-Error "latest.yml should contain exactly one size entry, found $($sizeLines.Count)" + exit 1 + } if (-not $blockmaps -or $blockmaps.Count -lt 1) { Write-Error "blockmap not found" exit 1 @@ -242,6 +247,11 @@ jobs: Write-Error "latest.yml not found" exit 1 } + $sizeLines = @(Select-String -Path "release/latest.yml" -Pattern '^\s+size:\s+\d+\s*$') + if ($sizeLines.Count -ne 1) { + Write-Error "latest.yml should contain exactly one size entry, found $($sizeLines.Count)" + exit 1 + } if (-not (Test-Path "release/force-update.json")) { Write-Error "force-update.json not found" exit 1 @@ -259,6 +269,7 @@ jobs: uses: softprops/action-gh-release@v2.5.0 with: tag_name: ${{ needs.prepare-meta.outputs.tag }} + name: CipherTalk v${{ needs.prepare-meta.outputs.version }} body_path: release/release-body.md fail_on_unmatched_files: false files: | diff --git a/.gitignore b/.gitignore index 21af4c2..5df2479 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ native-dlls resources/whisper xkey skills +.claude/ diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 4cda0ce..0000000 --- a/TODO.md +++ /dev/null @@ -1,82 +0,0 @@ -# EchoTrace 重构进度 - -## 基础架构 -- [x] 项目初始化(React + Electron + TypeScript) -- [x] 自定义标题栏 + Windows 原生窗口控件 -- [x] Zustand 状态管理 -- [x] Electron IPC 通信封装 -- [x] 数据库服务(SQLite) -- [x] 配置服务 -- [x] 路由守卫(未解密时跳转欢迎页) - -## 页面 -- [x] 欢迎页(WelcomePage) - - [x] 数据库路径选择 - - [x] 密钥输入/自动获取 - - [x] 解密进度显示 -- [x] 数据管理页(DataManagementPage) - - [x] 数据库列表扫描 - - [x] 解密状态显示 - - [x] 批量解密功能 - - [x] 增量更新功能 - - [x] 图片解密功能 -- [x] 聊天页(ChatPage) - - [x] 会话列表侧边栏 - - [x] 消息列表 - - [x] 消息气泡组件 - - [x] 图片消息 - - [x] 语音消息 - - [x] 表情消息 - - [x] 引用消息 - - [x] 系统消息 - - [x] 群聊发送者头像/名称 - - [x] 日期分隔线 - - [x] 滚动加载更多 - - [x] 图片消息查看器 - - [x] 语音消息播放 -- [x] 数据分析页(AnalyticsPage) - - [x] 消息统计图表 - - [x] 词云 - - [x] 活跃时段分析 -- [x] 年度报告页(AnnualReportPage) - - [x] 报告生成 - - [x] 报告展示 -- [x] 设置页(SettingsPage) - - [x] 数据库配置(密钥、路径、wxid) - - [x] 图片解密配置(XOR/AES 密钥,半完成,相当于没完成) - - [x] 缓存管理(迁移功能) - - [x] 主题切换 - - [x] 自动获取密钥功能 - - [x] 自动检测数据库路径 - -## 服务 -- [x] 数据管理服务 -- [x] 数据库解密服务 -- [x] 图片解密服务 -- [x] 图片密钥获取服务 -- [x] WCDB 数据库服务 -- [x] 微信密钥获取服务 -- [x] 聊天服务 -- [x] 表情包下载缓存服务 -- [x] 消息解析服务 -- [x] 语音解码服务 -- [x] 分析计算服务 -- [x] 导出服务 - -## 数据模型 -- [x] Message 消息模型 -- [x] Contact 联系人模型 -- [x] ChatSession 会话模型 -- [x] AnalyticsData 分析数据模型 - -## 组件 -- [x] TitleBar 标题栏 -- [x] Sidebar 侧边导航 -- [x] RouteGuard 路由守卫 -- [x] DecryptProgressOverlay 解密进度遮罩 -- [x] SessionAvatar 会话头像(支持骨架屏) -- [x] MessageBubble 消息气泡 -- [x] ImageViewer 图片查看器 -- [x] VoicePlayer 语音播放器 -- [x] LoadingSpinner 加载动画 -- [x] Toast 提示组件 diff --git a/electron/services/mcp/readService.ts b/electron/services/mcp/readService.ts index 1cae921..1571320 100644 --- a/electron/services/mcp/readService.ts +++ b/electron/services/mcp/readService.ts @@ -125,6 +125,18 @@ type MessageNormalizeOptions = { includeMediaPaths: boolean includeRaw: boolean } +type McpContactRef = { + contactId: string + sessionId: string + displayName: string + remark?: string + nickname?: string + kind: McpContactKind +} +type McpSessionLookupEntry = { + session: McpSessionRef + aliases: string[] +} type SearchRawHit = { session: McpSessionRef message: Message @@ -341,10 +353,23 @@ function toSessionItem(session: ChatSession): McpSessionItem { } } -function toContactItem(contact: ContactWithLastContact): McpContactItem { +function toContactRef(contact: ContactWithLastContact): McpContactRef { + return { + contactId: contact.username, + sessionId: contact.username, + displayName: contact.displayName, + remark: contact.remark || undefined, + nickname: contact.nickname || undefined, + kind: contact.type as McpContactKind + } +} + +function toContactItem(contact: ContactWithLastContact, hasSession: boolean): McpContactItem { const lastContactTimestamp = Number(contact.lastContactTime || 0) return { contactId: contact.username, + sessionId: contact.username, + hasSession, displayName: contact.displayName, remark: contact.remark || undefined, nickname: contact.nickname || undefined, @@ -354,11 +379,199 @@ function toContactItem(contact: ContactWithLastContact): McpContactItem { } } -function resolveSessionRef(sessionId: string, sessionMap: Map): McpSessionRef { - return sessionMap.get(sessionId) || { - sessionId, - displayName: sessionId, - kind: detectSessionKind(sessionId) +function buildContactSearchKeys(contact: McpContactRef): string[] { + return [ + contact.contactId, + contact.sessionId, + contact.displayName, + contact.remark || '', + contact.nickname || '' + ] + .map((value) => String(value || '').trim()) + .filter(Boolean) +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const value of values) { + const trimmed = String(value || '').trim() + if (!trimmed) continue + const normalized = normalizeQuery(trimmed) + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + result.push(trimmed) + } + return result +} + +function isSubsequence(query: string, target: string): boolean { + let qi = 0 + let ti = 0 + while (qi < query.length && ti < target.length) { + if (query[qi] === target[ti]) qi += 1 + ti += 1 + } + return qi === query.length +} + +function scoreLookupValue(query: string, rawTarget: string): number { + const target = normalizeQuery(rawTarget) + if (!query || !target) return 0 + if (target === query) return 1000 + if (target.startsWith(query)) return 820 + Math.min(query.length * 8, 120) + if (target.includes(query)) return 640 + Math.min(query.length * 6, 100) - Math.min(Math.max(target.length - query.length, 0), 80) + if (query.startsWith(target)) return 420 + Math.min(target.length * 5, 80) + if (isSubsequence(query, target)) return 260 + Math.min(query.length * 4, 60) + return 0 +} + +function buildSessionLookupEntries( + sessions: McpSessionItem[], + contacts: McpContactRef[] +): McpSessionLookupEntry[] { + const entryMap = new Map() + + for (const session of sessions) { + entryMap.set(session.sessionId, { + session: { + sessionId: session.sessionId, + displayName: session.displayName, + kind: session.kind + }, + aliases: uniqueStrings([session.sessionId, session.displayName]) + }) + } + + for (const contact of contacts) { + const entry = entryMap.get(contact.sessionId) + if (!entry) continue + entry.aliases = uniqueStrings([ + ...entry.aliases, + contact.contactId, + contact.displayName, + contact.remark || '', + contact.nickname || '' + ]) + } + + return Array.from(entryMap.values()) +} + +function formatSessionCandidateHint(rawInput: string, candidates: McpSessionLookupEntry[]): string { + if (candidates.length === 0) { + return `未找到与“${rawInput}”匹配的会话。可先用 list_sessions 或 list_contacts 做泛搜索。` + } + + const preview = candidates + .slice(0, 5) + .map((candidate) => `- ${candidate.session.displayName} (${candidate.session.sessionId})`) + .join('\n') + + return `“${rawInput}”匹配到多个候选,请改用更具体的信息重试:\n${preview}` +} + +async function getContactCatalog(): Promise<{ items: McpContactRef[]; map: Map }> { + const result = await chatService.getContacts() + if (!result.success) { + mapChatError(result.error) + } + + const items = (result.contacts || []).map((contact) => toContactRef(contact as ContactWithLastContact)) + const map = new Map() + + for (const item of items) { + for (const key of buildContactSearchKeys(item)) { + map.set(normalizeQuery(key), item) + } + } + + return { items, map } +} + +function tryResolveContactRef( + rawValue: string, + contactMap: Map +): McpContactRef | null { + const normalized = normalizeQuery(rawValue) + if (!normalized) return null + + const exact = contactMap.get(normalized) + if (exact) return exact + + const partialMatches = Array.from(new Set(contactMap.values().filter((contact) => + buildContactSearchKeys(contact).some((value) => normalizeQuery(value).includes(normalized)) + ))) + + return partialMatches.length === 1 ? partialMatches[0] : null +} + +function findSessionCandidates( + rawInput: string, + sessions: McpSessionItem[], + contacts: McpContactRef[] +): Array<{ entry: McpSessionLookupEntry; score: number }> { + const query = normalizeQuery(rawInput) + if (!query) return [] + + return buildSessionLookupEntries(sessions, contacts) + .map((entry) => ({ + entry, + score: Math.max(...entry.aliases.map((alias) => scoreLookupValue(query, alias)), 0) + })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || a.entry.session.displayName.localeCompare(b.entry.session.displayName, 'zh-CN')) +} + +function resolveSessionRefStrict( + rawInput: string, + sessions: McpSessionItem[], + sessionMap: Map, + contacts: McpContactRef[], + contactMap: Map +): McpSessionRef { + const direct = resolveSessionRef(rawInput, sessionMap, contactMap) + if (sessionMap.has(direct.sessionId)) { + return direct + } + + const candidates = findSessionCandidates(rawInput, sessions, contacts) + if (candidates.length === 0) { + throw new McpToolError('SESSION_NOT_FOUND', 'Session not found.', formatSessionCandidateHint(rawInput, [])) + } + + const [first, second] = candidates + if (candidates.length === 1 || !second || first.score - second.score >= 140 || first.score >= 1000) { + return first.entry.session + } + + throw new McpToolError('BAD_REQUEST', 'Session is ambiguous.', formatSessionCandidateHint( + rawInput, + candidates.map((item) => item.entry) + )) +} + +function resolveSessionRef( + rawSessionId: string, + sessionMap: Map, + contactMap?: Map +): McpSessionRef { + const directSession = sessionMap.get(rawSessionId) + if (directSession) return directSession + + const contact = contactMap ? tryResolveContactRef(rawSessionId, contactMap) : null + if (contact) { + return sessionMap.get(contact.sessionId) || { + sessionId: contact.sessionId, + displayName: contact.displayName || contact.sessionId, + kind: detectSessionKind(contact.sessionId) + } + } + + return { + sessionId: rawSessionId, + displayName: rawSessionId, + kind: detectSessionKind(rawSessionId) } } @@ -643,14 +856,27 @@ export class McpReadService { const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT) const unreadOnly = Boolean(args.data.unreadOnly) - let sessions = (await getSessionCatalog()).items + const [{ items: sessionItems }, { map: contactMap }] = await Promise.all([ + getSessionCatalog(), + getContactCatalog() + ]) + + let sessions = sessionItems if (query) { sessions = sessions.filter((session) => { return [ session.sessionId, session.displayName, - session.lastMessagePreview + session.lastMessagePreview, + ...buildContactSearchKeys(contactMap.get(normalizeQuery(session.sessionId)) || { + contactId: session.sessionId, + sessionId: session.sessionId, + displayName: '', + remark: '', + nickname: '', + kind: session.kind === 'group' ? 'group' : session.kind === 'official' ? 'official' : 'friend' + }) ].some((value) => value.toLowerCase().includes(query)) }) } @@ -687,7 +913,11 @@ export class McpReadService { mapChatError(result.error) } - let contacts = (result.contacts || []).map((contact) => toContactItem(contact as ContactWithLastContact)) + const { map: sessionMap } = await getSessionCatalog() + let contacts = (result.contacts || []).map((contact) => { + const typedContact = contact as ContactWithLastContact + return toContactItem(typedContact, sessionMap.has(typedContact.username)) + }) if (typeSet) { contacts = contacts.filter((contact) => typeSet.has(contact.kind)) @@ -789,7 +1019,7 @@ export class McpReadService { } const { - sessionId, + sessionId: rawSessionId, keyword, includeRaw = false, order = 'asc' @@ -801,6 +1031,12 @@ export class McpReadService { const keywordQuery = normalizeQuery(keyword) const startTimeMs = toTimestampMs(args.data.startTime) const endTimeMs = toTimestampMs(args.data.endTime) + const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([ + getSessionCatalog(), + getContactCatalog() + ]) + const session = resolveSessionRefStrict(rawSessionId, sessions, sessionMap, contacts, contactMap) + const sessionId = session.sessionId const matched: Message[] = [] let scanOffset = 0 @@ -854,7 +1090,10 @@ export class McpReadService { throw new McpToolError('BAD_REQUEST', 'Invalid search_messages arguments.', args.error.message) } - const { items: sessions, map: sessionMap } = await getSessionCatalog() + const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([ + getSessionCatalog(), + getContactCatalog() + ]) const includeRaw = args.data.includeRaw ?? false const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths const limit = Math.min(args.data.limit ?? 20, MAX_SEARCH_LIMIT) @@ -869,8 +1108,8 @@ export class McpReadService { } const targetSessions = sessionIdCandidates.length > 0 - ? sessionIdCandidates.map((sessionId) => resolveSessionRef(sessionId, sessionMap)) - : sessions.slice(0, MAX_SEARCH_SESSIONS).map((session) => ({ + ? sessionIdCandidates.map((sessionId) => resolveSessionRefStrict(sessionId, sessions, sessionMap, contacts, contactMap)) + : sessions.map((session) => ({ sessionId: session.sessionId, displayName: session.displayName, kind: session.kind @@ -995,19 +1234,23 @@ export class McpReadService { throw new McpToolError('BAD_REQUEST', 'Invalid get_session_context arguments.', args.error.message) } - const { map: sessionMap } = await getSessionCatalog() - const session = resolveSessionRef(args.data.sessionId, sessionMap) + const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([ + getSessionCatalog(), + getContactCatalog() + ]) + const session = resolveSessionRefStrict(args.data.sessionId, sessions, sessionMap, contacts, contactMap) + const resolvedSessionId = session.sessionId const includeRaw = args.data.includeRaw ?? false const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths if (args.data.mode === 'latest') { const latestLimit = Math.min(args.data.beforeLimit ?? 30, MAX_CONTEXT_LIMIT) - const result = await chatService.getMessages(args.data.sessionId, 0, latestLimit) + const result = await chatService.getMessages(resolvedSessionId, 0, latestLimit) if (!result.success) { mapChatError(result.error) } - const messages = await normalizeMessages(args.data.sessionId, result.messages || [], { + const messages = await normalizeMessages(resolvedSessionId, result.messages || [], { includeMediaPaths, includeRaw }) @@ -1027,21 +1270,21 @@ export class McpReadService { const [beforeResult, anchorResult, afterResult] = await Promise.all([ chatService.getMessagesBefore( - args.data.sessionId, + resolvedSessionId, anchorCursor.sortSeq, beforeLimit, anchorCursor.createTime, anchorCursor.localId ), chatService.getMessagesAfter( - args.data.sessionId, + resolvedSessionId, anchorCursor.sortSeq, 1, anchorCursor.createTime, anchorCursor.localId - 1 ), chatService.getMessagesAfter( - args.data.sessionId, + resolvedSessionId, anchorCursor.sortSeq, afterLimit, anchorCursor.createTime, @@ -1059,15 +1302,15 @@ export class McpReadService { } const [beforeItems, anchorItem, afterItems] = await Promise.all([ - normalizeMessages(args.data.sessionId, beforeResult.messages || [], { + normalizeMessages(resolvedSessionId, beforeResult.messages || [], { includeMediaPaths, includeRaw }), - normalizeMessage(args.data.sessionId, anchorMessage, { + normalizeMessage(resolvedSessionId, anchorMessage, { includeMediaPaths, includeRaw }), - normalizeMessages(args.data.sessionId, afterResult.messages || [], { + normalizeMessages(resolvedSessionId, afterResult.messages || [], { includeMediaPaths, includeRaw }) diff --git a/electron/services/mcp/tools.ts b/electron/services/mcp/tools.ts index 2f4f157..c7f81e1 100644 --- a/electron/services/mcp/tools.ts +++ b/electron/services/mcp/tools.ts @@ -102,7 +102,7 @@ export function registerCipherTalkMcpTools(server: any) { title: 'Get Messages', description: 'List messages from one chat session with filters and pagination.', inputSchema: { - sessionId: z.string().trim().min(1).describe('Required session identifier / username.'), + sessionId: z.string().trim().min(1).describe('Required session identifier. Accepts sessionId, contactId, display name, remark, or nickname when uniquely resolvable.'), offset: z.number().int().nonnegative().optional().describe('Pagination offset.'), limit: z.number().int().positive().optional().describe('Pagination limit.'), order: z.enum(['asc', 'desc']).optional().describe('Message sort order by time.'), @@ -145,8 +145,8 @@ export function registerCipherTalkMcpTools(server: any) { description: 'Search messages across one or more sessions and return agent-friendly hits.', inputSchema: { query: z.string().trim().min(1).describe('Required full-text query.'), - sessionId: z.string().trim().min(1).optional().describe('Single session identifier to search.'), - sessionIds: z.array(z.string().trim().min(1)).max(20).optional().describe('Multiple session identifiers to search.'), + sessionId: z.string().trim().min(1).optional().describe('Single session identifier to search. Accepts sessionId, contactId, display name, remark, or nickname when uniquely resolvable.'), + sessionIds: z.array(z.string().trim().min(1)).max(20).optional().describe('Multiple session identifiers to search. Each item accepts sessionId, contactId, display name, remark, or nickname when uniquely resolvable.'), startTime: z.number().int().positive().optional().describe('Start timestamp in seconds or milliseconds.'), endTime: z.number().int().positive().optional().describe('End timestamp in seconds or milliseconds.'), kinds: z.array(z.enum(MCP_MESSAGE_KINDS)).optional().describe('Optional message kinds to include.'), @@ -171,7 +171,7 @@ export function registerCipherTalkMcpTools(server: any) { title: 'Get Session Context', description: 'Return the latest session context or messages around a cursor anchor.', inputSchema: { - sessionId: z.string().trim().min(1).describe('Required session identifier / username.'), + sessionId: z.string().trim().min(1).describe('Required session identifier. Accepts sessionId, contactId, display name, remark, or nickname when uniquely resolvable.'), mode: z.enum(['latest', 'around']).describe('Context mode.'), anchorCursor: z.object({ sortSeq: z.number().int(), diff --git a/electron/services/mcp/types.ts b/electron/services/mcp/types.ts index f3e56bf..92d7b84 100644 --- a/electron/services/mcp/types.ts +++ b/electron/services/mcp/types.ts @@ -124,6 +124,8 @@ export interface McpSessionsPayload { export interface McpContactItem { contactId: string + sessionId?: string + hasSession?: boolean displayName: string remark?: string nickname?: string diff --git a/scripts/add-size-to-yml.js b/scripts/add-size-to-yml.js index bb8d5cc..4c58eef 100644 --- a/scripts/add-size-to-yml.js +++ b/scripts/add-size-to-yml.js @@ -9,79 +9,114 @@ if (!fs.existsSync(ymlPath)) { process.exit(0) } -// 读取 yml 内容 -const content = fs.readFileSync(ymlPath, 'utf-8') -const lines = content.split('\n') +function getExeName(content) { + const pathMatch = content.match(/path:\s*(.+\.exe)/) + if (pathMatch) { + return pathMatch[1].trim() + } -// 从 yml 中提取文件名 -const match = content.match(/path:\s*(.+\.exe)/) -if (!match) { + const urlMatch = content.match(/-\s+url:\s*(.+\.exe)/) + if (urlMatch) { + return urlMatch[1].trim() + } + + return null +} + +function finalizeFileItem(itemLines, size) { + if (itemLines.length === 0) return itemLines + + const cleanedLines = itemLines.filter((line) => !line.trim().startsWith('size:')) + const shaIndex = cleanedLines.findIndex((line) => line.trim().startsWith('sha512:')) + const itemIndent = `${cleanedLines[0].match(/^\s*/)?.[0] || ' '} ` + const sizeLine = `${itemIndent}size: ${size}` + + if (shaIndex >= 0) { + cleanedLines.splice(shaIndex + 1, 0, sizeLine) + } else { + cleanedLines.push(sizeLine) + } + + return cleanedLines +} + +function normalizeLatestYml(content, size) { + const lines = content.split(/\r?\n/) + const filesIndex = lines.findIndex((line) => line.trim() === 'files:') + if (filesIndex === -1) { + return { changed: false, content, message: '未找到 files 块' } + } + + let blockEnd = lines.length + for (let i = filesIndex + 1; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.trim() + if (!trimmed) continue + if (!line.startsWith(' ') && !line.startsWith('\t')) { + blockEnd = i + break + } + } + + const before = lines.slice(0, filesIndex + 1) + const fileBlock = lines.slice(filesIndex + 1, blockEnd) + const after = lines.slice(blockEnd) + + const normalizedBlock = [] + let currentItem = [] + let handledFirstItem = false + + const flushItem = () => { + if (currentItem.length === 0) return + normalizedBlock.push(...(handledFirstItem ? currentItem : finalizeFileItem(currentItem, size))) + handledFirstItem = true + currentItem = [] + } + + for (const line of fileBlock) { + const trimmed = line.trim() + if (trimmed.startsWith('- ')) { + flushItem() + currentItem.push(line) + continue + } + + if (currentItem.length > 0) { + currentItem.push(line) + } else { + normalizedBlock.push(line) + } + } + + flushItem() + + const nextContent = [...before, ...normalizedBlock, ...after].join('\n') + return { + changed: nextContent !== content, + content: nextContent, + message: nextContent !== content ? `已规范 latest.yml 中的 size 字段为 ${size}` : 'latest.yml 中的 size 字段已正确' + } +} + +const content = fs.readFileSync(ymlPath, 'utf-8') +const exeName = getExeName(content) + +if (!exeName) { console.log('未找到安装包文件名') process.exit(0) } -const exeName = match[1].trim() const exePath = path.join(releaseDir, exeName) - if (!fs.existsSync(exePath)) { console.log(`安装包不存在: ${exeName}`) process.exit(0) } -// 获取文件大小 -const stats = fs.statSync(exePath) -const size = stats.size +const size = fs.statSync(exePath).size +const result = normalizeLatestYml(content, size) -// electron-builder 新版本已经会生成 files[0].size,这里只在缺失时补齐,避免写出重复键 -const newLines = [] -let inFiles = false -let sizeAdded = false -let fileItemIndent = '' - -for (let i = 0; i < lines.length; i++) { - const line = lines[i] - newLines.push(line) - - if (line.startsWith('files:')) { - inFiles = true - fileItemIndent = '' - continue - } - - if (!inFiles) { - continue - } - - const trimmed = line.trim() - const indent = line.match(/^\s*/)?.[0] || '' - - if (trimmed.startsWith('- ')) { - fileItemIndent = `${indent} ` - continue - } - - if (trimmed.startsWith('size:')) { - console.log('latest.yml 已包含 size,跳过写入') - process.exit(0) - } - - // 离开 files 块 - if (trimmed && !line.startsWith(' ') && !line.startsWith('\t')) { - inFiles = false - continue - } - - // 在 files 块内的第一个 sha512 后添加 size - if (!sizeAdded && trimmed.startsWith('sha512:')) { - newLines.push(`${fileItemIndent || ' '}size: ${size}`) - sizeAdded = true - inFiles = false - } +if (result.changed) { + fs.writeFileSync(ymlPath, result.content) } -if (sizeAdded) { - fs.writeFileSync(ymlPath, newLines.join('\n')) - console.log(`已添加 size: ${size} 到 latest.yml`) -} else { - console.log('未找到合适位置插入 size') -} +console.log(result.message) diff --git a/scripts/generate-release-body.js b/scripts/generate-release-body.js index b7ae3aa..de1d3bf 100644 --- a/scripts/generate-release-body.js +++ b/scripts/generate-release-body.js @@ -50,6 +50,7 @@ const localSecrets = loadLocalSecretEnv() const aiApiKey = process.env.AI_API_KEY || localSecrets.AI_API_KEY || '' const aiApiUrl = process.env.AI_API_URL || localSecrets.AI_API_URL || 'https://api.openai.com/v1/chat/completions' const aiModel = process.env.AI_MODEL || localSecrets.AI_MODEL || 'gpt-5.4' +const PRODUCT_NAME = 'CipherTalk' const PRIMARY_AUTHOR_LOGINS = new Set(['ILoveBingLu']) const PRIMARY_AUTHOR_NAMES = new Set(['ILoveBingLu', 'BingLu', 'ILoveBinglu']) @@ -100,6 +101,21 @@ function buildReferences(context) { return lines } +function inferReleaseTone(context) { + const subjects = (context.commits || []).map((commit) => String(commit.subject || '').toLowerCase()) + if (subjects.some((subject) => subject.startsWith('feat'))) return '新功能开始成型' + if (subjects.some((subject) => subject.startsWith('fix'))) return '这次重点在修整体验' + if (subjects.some((subject) => subject.includes('release') || subject.includes('workflow') || subject.includes('ci'))) { + return '发布链路做了一轮收口' + } + if ((context.commits || []).length >= 5) return '这一版主要在做内部打磨' + return '这次更新以稳定和整理为主' +} + +function buildReleaseTitle(context) { + return `${PRODUCT_NAME} v${context.version} · ${inferReleaseTone(context)}` +} + function buildFallbackBody(context) { const groups = { 新增: [], @@ -115,21 +131,34 @@ function buildFallbackBody(context) { const references = buildReferences(context) const blockedVersions = context.forceUpdate?.blockedVersions || [] const hasUpgradeReminder = Boolean(context.forceUpdate?.minimumSupportedVersion || blockedVersions.length > 0) + const totalCommits = (context.commits || []).length + const totalPrs = (context.pullRequests || []).length + const touchedAreas = Object.entries(groups) + .filter(([, items]) => items.length > 0) + .map(([name]) => name) + const summary = touchedAreas.length + ? `这次共整理了 ${totalCommits} 条提交${totalPrs ? `、${totalPrs} 个 PR` : ''},重点落在 ${touchedAreas.join(' / ')}。` + : `这次共整理了 ${totalCommits} 条提交${totalPrs ? `、${totalPrs} 个 PR` : ''},整体以维护性调整为主。` return [ - `## CipherTalk ${context.tag}`, + `## ${buildReleaseTitle(context)}`, '', - '### 概览', - `本版本包含 ${context.commits.length} 条提交,涉及 ${(context.pullRequests || []).length} 个 PR。`, + `> ${summary}`, '', - '### 新增', - ...(groups.新增.length ? groups.新增 : ['- 无']), + '### 这次更新', + `- ${inferReleaseTone(context)}。`, + `- ${summary}`, '', - '### 修复', - ...(groups.修复.length ? groups.修复 : ['- 无']), + '### 变更明细', '', - '### 调整', - ...(groups.调整.length ? groups.调整 : ['- 无']), + '#### 新增', + ...(groups.新增.length ? groups.新增 : ['- 本次没有单独拎出来的新功能提交']), + '', + '#### 修复', + ...(groups.修复.length ? groups.修复 : ['- 本次没有明确归类为缺陷修复的提交']), + '', + '#### 调整', + ...(groups.调整.length ? groups.调整 : ['- 本次主要是零散维护项']), '', ...(hasUpgradeReminder ? [ '### 升级提醒', @@ -148,7 +177,7 @@ function buildFallbackBody(context) { function isValidAiBody(body) { if (!body) return false - return body.includes('## CipherTalk') && body.includes('### 感谢贡献者') && body.includes('### 相关提交与 PR') + return body.includes(`## ${PRODUCT_NAME}`) && body.includes('### 感谢贡献者') && body.includes('### 相关提交与 PR') } function logAiConfig() { @@ -170,25 +199,42 @@ async function generateAiBody(context) { const systemPrompt = [ '你是一个发布说明撰写助手。', '只能基于输入中的 commits 和 pull requests 生成,不得编造任何功能或修复。', - '输出必须是中文 Markdown,风格尽量自然,不要机械复读。', - '为保证格式一致性:优先使用以下标题结构(即使某一类内容为空也要写出对应章节,并在该章节内标注“无/未检测到”):', - '## CipherTalk vX.Y.Z', - '### 概览', - '### 新增', - '### 修复', - '### 调整', + '输出必须是中文 Markdown,风格要自然,像真实产品版本说明,不要写成死板模板。', + '标题必须包含软件名,不能只写版本号。', + '第一行使用格式:## CipherTalk vX.Y.Z · 一句简短版本名', + '第二段使用一行引用块(>)写一句导语,概括这次更新的重心。', + '正文优先使用以下结构:', + '### 这次更新', + '### 变更明细', + '#### 新增', + '#### 修复', + '#### 调整', '### 感谢贡献者', '### 相关提交与 PR', + '如果上方有些内容没有,即可用一些涩话来填充,不要显得很死板或机械。', '如果存在最低安全版本或封禁版本,增加 ### 升级提醒 章节。', '分类建议:可参考提交标题前缀 feat/fix 做粗分类到 新增/修复;其余放到 调整(如果标题无法判断,就放到 调整)。', + '如果某个分类为空,不要反复写“无/未检测到”这种机械表达,可以改成更自然但仍然克制的表述。', + '如果这次主要是 chore、ci、release、workflow、refactor,也要把这些工程改动翻译成用户能理解的影响,比如“发布链路更稳”“版本分发更顺”“维护成本更低”,但不能编造功能。', '引用规则:', '有 PR 时优先引用 PR 标题;没有 PR 时才引用 commit 标题。', - '列表尽量短:最多每类列出 5 条最关键的标题;其余可在概览里用一句话说明总量。', + '列表尽量短:最多每类列出 5 条最关键的标题;其余可在导语或“这次更新”里用一句话说明总量。', '感谢规则:只有非主作者的 PR/commit 才出现在感谢段;主作者按代码中的逻辑是 ILoveBingLu(及其大小写/拼写变体)相关。', - '不要写猜测:如果输入里没有足够信息,就用“无/未检测到”或“仅维护性发布”描述。' + '不要写猜测:如果输入里没有足够信息,就明确说这次以内部整理、稳定性、发布链路或工程维护为主。', + '不要输出代码块,不要输出 JSON,不要套娃标题。' ].join('\n') - const userPrompt = `请根据以下发布上下文为 ${context.tag} 生成标准化发布说明:\n\n${JSON.stringify(context, null, 2)}` + const userPrompt = [ + `请根据以下发布上下文,为 ${PRODUCT_NAME} ${context.tag} 生成一份更有辨识度的发布说明。`, + '附加要求:', + `- 标题必须带 ${PRODUCT_NAME} 和版本号,并给这次版本起一个简短名字。`, + '- 不要每次都复用同一套句式。', + '- 如果提交主要是发布流程、CI、脚本、环境变量之类的工程项,也要写出它们对发版和分发稳定性的意义。', + '- 保留“感谢贡献者”和“相关提交与 PR”章节。', + '- 你是一个类似伤感者的文案大师,写出来的东西要有温度和辨识度,不要死板无趣。', + '', + JSON.stringify(context, null, 2) + ].join('\n') const startedAt = Date.now() console.log(`[ReleaseBody] AI request start for ${context.tag}`) @@ -200,7 +246,7 @@ async function generateAiBody(context) { }, body: JSON.stringify({ model: aiModel, - temperature: 0.2, + temperature: 0.7, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }