diff --git a/.gitignore b/.gitignore index dbb03b8..6f92830 100644 --- a/.gitignore +++ b/.gitignore @@ -13,15 +13,11 @@ resources/connectors/wechat/ resources/connectors/wechat-personal/ .omc .codex/ -services/share-card-worker/.wrangler/ -services/share-card-worker/wrangler.local.jsonc skills-lock.json -docs/design/ -docs/ui-redesign-plan.md -docs/ui-redesign-spec.md AGENTS.md findings.md progress.md task_plan.md .agents -*__screenshots__ \ No newline at end of file +*__screenshots__ +.ai-local \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index ed9a199..e8873f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,7 +16,7 @@ import { dialog, protocol } from 'electron' -import { basename, dirname, extname, join } from 'path' +import { dirname, extname, join } from 'path' import { existsSync, promises as fsPromises } from 'fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' @@ -113,12 +113,17 @@ import { installSafeConsole } from './safe-log' import { agentHubService } from './services/agent-hub-service' import { groupExitMonitorService } from './services/group-exit-monitor-service' import { wechatActionLogService } from './services/wechat-action-log-service' +import { wechatActionGateway } from './services/wechat-action-gateway' import { personalWechatSendService } from './services/personal-wechat-send-service' import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service' import { scheduledReportService } from './services/scheduled-report-service' import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager' import { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service' -import type { PersonalWechatSendRequest } from '../shared/personal-wechat' +import type { + PersonalWechatGeneratedTtsVoiceRequest, + PersonalWechatSendRequest, + PersonalWechatSendResult +} from '../shared/personal-wechat' import type { ScheduledReportCreateInput, ScheduledReportUpdateInput @@ -911,6 +916,20 @@ app.whenReady().then(async () => { } } ) + // 日报等系统动作仍复用现有发送服务;普通聊天不再暴露这个入口。 + ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => { + if (request.type !== 'voice' || String(request.fromId || '').trim()) { + return personalWechatSendService.send(request) + } + let fromId = '' + try { + const self = await chat.getSelfAccountInfoAsync() + fromId = String(self?.wxid || '').trim() + } catch { + // 无法读取账号 wxid 时仍交给发送服务返回规范化错误。 + } + return personalWechatSendService.send(fromId ? { ...request, fromId } : request) + }) ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => { const settings = loadSettings() @@ -1980,52 +1999,40 @@ app.whenReady().then(async () => { return error ? { success: false, error } : { success: true } }) ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind()) - ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => { - if (request.type !== 'voice' || String(request.fromId || '').trim()) { - return personalWechatSendService.send(request) + ipcMain.handle( + 'wechat-personal:sendGeneratedTtsVoice', + async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => { + const to = String(request?.to || '').trim() + const filePath = String(request?.filePath || '').trim() + let fromId = '' + try { + const self = await chat.getSelfAccountInfoAsync() + fromId = String(self?.wxid || '').trim() + } catch { + // Windows 语音发送需要当前账号 wxid;Gateway 会继续规范化失败结果。 + } + const action = await wechatActionGateway.execute({ + origin: 'user_tts', + purpose: 'tts_voice', + triggerType: 'user', + recipient: { + type: request?.isGroup || to.endsWith('@chatroom') ? 'group' : 'contact', + id: to + }, + content: { type: 'voice', path: filePath }, + metadata: fromId ? { fromId } : undefined + }) + const sendResult = + action.sendResult && typeof action.sendResult === 'object' + ? (action.sendResult as PersonalWechatSendResult) + : undefined + const status = sendResult?.status || (await personalWechatSendService.getStatus()) + return { action, status } } - let fromId = '' - try { - const self = await chat.getSelfAccountInfoAsync() - fromId = String(self?.wxid || '').trim() - } catch { - // The sender can still operate without an account wxid for non-voice messages. - } - return personalWechatSendService.send(fromId ? { ...request, fromId } : request) - }) + ) ipcMain.handle('wechat-personal:getVoiceDiagnostic', () => personalWechatSendService.getLatestVoiceDiagnostic() ) - ipcMain.handle('wechat-personal:selectImage', async (event) => { - const window = BrowserWindow.fromWebContents(event.sender) - const result = await dialog.showOpenDialog(window!, { - title: '选择要通过个人微信发送的图片', - properties: ['openFile'], - filters: [ - { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }, - { name: '所有文件', extensions: ['*'] } - ] - }) - if (result.canceled || !result.filePaths[0]) return { canceled: true } - return { - canceled: false, - path: result.filePaths[0], - name: basename(result.filePaths[0]) - } - }) - ipcMain.handle('wechat-personal:selectVoice', async (event) => { - const window = BrowserWindow.fromWebContents(event.sender) - const result = await dialog.showOpenDialog(window!, { - title: '选择要通过个人微信发送的语音', - properties: ['openFile'], - filters: [ - { name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] }, - { name: '所有文件', extensions: ['*'] } - ] - }) - if (result.canceled || !result.filePaths[0]) return { canceled: true } - return { canceled: false, path: result.filePaths[0], name: basename(result.filePaths[0]) } - }) ipcMain.handle('agent-hub:selectTestImage', async (event) => { const window = BrowserWindow.fromWebContents(event.sender) const result = await dialog.showOpenDialog(window!, { diff --git a/src/main/services/wechat-action-gateway.ts b/src/main/services/wechat-action-gateway.ts index 4c49387..1087830 100644 --- a/src/main/services/wechat-action-gateway.ts +++ b/src/main/services/wechat-action-gateway.ts @@ -584,7 +584,19 @@ function toPersonalWechatSendRequest(request: WechatActionRequest): PersonalWech if (request.content.type === 'image') { return { ...base, type: 'image', filePath: request.content.path } } - return { ...base, type: 'voice', filePath: request.content.path } + const metadata = request.metadata && typeof request.metadata === 'object' ? request.metadata : {} + const fromId = typeof metadata.fromId === 'string' ? metadata.fromId.trim() : '' + const durationMs = + typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs) + ? metadata.durationMs + : undefined + return { + ...base, + type: 'voice', + filePath: request.content.path, + ...(fromId ? { fromId } : {}), + ...(durationMs !== undefined ? { durationMs } : {}) + } } function contentAudit( diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a63cf3f..be39348 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -54,8 +54,8 @@ import type { } from '../shared/image-insight' import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' import type { - PersonalWechatImageSelectionResult, - PersonalWechatVoiceSelectionResult, + PersonalWechatGeneratedTtsVoiceRequest, + PersonalWechatGeneratedTtsVoiceResult, PersonalWechatSendRequest, PersonalWechatSendResult, PersonalWechatSenderStatus, @@ -637,8 +637,9 @@ declare global { callback: (status: PersonalWechatRuntimeProgressEvent) => void ) => () => void rebindPersonalWechatSender: () => Promise - selectPersonalWechatImage: () => Promise - selectPersonalWechatVoice: () => Promise + sendGeneratedTtsVoice: ( + request: PersonalWechatGeneratedTtsVoiceRequest + ) => Promise sendPersonalWechatMessage: ( request: PersonalWechatSendRequest ) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 50019de..be7f9b1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,8 +26,8 @@ import type { } from '../shared/image-insight' import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub' import type { - PersonalWechatImageSelectionResult, - PersonalWechatVoiceSelectionResult, + PersonalWechatGeneratedTtsVoiceRequest, + PersonalWechatGeneratedTtsVoiceResult, PersonalWechatSendRequest, PersonalWechatSendResult, PersonalWechatSenderStatus, @@ -438,10 +438,11 @@ const api = { }, rebindPersonalWechatSender: (): Promise => ipcRenderer.invoke('wechat-personal:rebind'), - selectPersonalWechatImage: (): Promise => - ipcRenderer.invoke('wechat-personal:selectImage'), - selectPersonalWechatVoice: (): Promise => - ipcRenderer.invoke('wechat-personal:selectVoice'), + sendGeneratedTtsVoice: ( + request: PersonalWechatGeneratedTtsVoiceRequest + ): Promise => + ipcRenderer.invoke('wechat-personal:sendGeneratedTtsVoice', request), + // 日报图片发送仍使用现有业务发送链路;普通聊天 Composer 不暴露此入口。 sendPersonalWechatMessage: ( request: PersonalWechatSendRequest ): Promise => ipcRenderer.invoke('wechat-personal:send', request), diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index 52e1152..f8b4fb1 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -124,6 +124,25 @@ const ChatWindow: React.FC = ({ return typeMatch && contentMatch }) }, [messages, contentFilter]) + + const handleOpenPersonalWechatSend = useCallback(async (): Promise => { + try { + const status = await window.api.getPersonalWechatSenderStatus() + if (!status.canSendVoice) { + if (onOpenPersonalWechatSettings) { + onOpenPersonalWechatSettings() + return + } + } + } catch { + if (onOpenPersonalWechatSettings) { + onOpenPersonalWechatSettings() + return + } + } + setSendDialogOpen(true) + }, [onOpenPersonalWechatSettings]) + if (!contact) return return ( @@ -138,7 +157,7 @@ const ChatWindow: React.FC = ({ onContentFilterChange={onContentFilterChange || (() => undefined)} onRefresh={onRefresh} onRefreshData={onRefreshData} - onTestSend={() => setSendDialogOpen(true)} + onTestSend={() => void handleOpenPersonalWechatSend()} onOpenAiSettings={onCreateGroupReport || (() => undefined)} /> diff --git a/src/renderer/src/components/chat/ChatHeader.tsx b/src/renderer/src/components/chat/ChatHeader.tsx index 70dba60..511f2cc 100644 --- a/src/renderer/src/components/chat/ChatHeader.tsx +++ b/src/renderer/src/components/chat/ChatHeader.tsx @@ -106,11 +106,12 @@ export function ChatHeader({ variant="outline" size="sm" className="chat-header-text-action" - aria-label="发送消息" + aria-label="文字转语音" + title="文字转语音" onClick={onTestSend} > - 发送消息 + 文字转语音 ) : ( @@ -120,12 +121,13 @@ export function ChatHeader({ variant="outline" size="sm" className="chat-header-text-action" - aria-label="发送消息" + aria-label="文字转语音" + title="文字转语音" onClick={onTestSend} disabled > - 发送消息 + 文字转语音 diff --git a/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx b/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx index 44fd62a..bdc09fc 100644 --- a/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx +++ b/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx @@ -1,19 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import type { - PersonalWechatSendRequest, - PersonalWechatSenderStatus -} from '../../../../shared/personal-wechat' +import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat' import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech' -import { Button, SegmentedControl, SegmentedControlItem, Textarea } from '../ui' +import { Button, Textarea } from '../ui' -export type PersonalWechatComposerMode = 'text' | 'image' | 'voice' -type VoiceSource = 'generated' | 'file' -type SelectedLocalFile = { path: string; name: string } type GeneratedVoice = { filePath: string; audioDataUrl: string } export type ChatMessage = { id: string - type: PersonalWechatComposerMode | 'system' + type: 'voice' | 'system' text?: string fileName?: string outgoing: boolean @@ -23,15 +17,9 @@ interface PersonalWechatChatComposerProps { className?: string status: PersonalWechatSenderStatus targetId: string - isGroupChat: boolean - initialMode?: PersonalWechatComposerMode - initialImage?: SelectedLocalFile | null onOpenTextToSpeechSettings?: () => void onCancel: () => void - onSend: ( - request: PersonalWechatSendRequest, - displayText: string - ) => Promise<{ success: boolean; error?: string }> + onSend: (filePath: string, displayText: string) => Promise<{ success: boolean; error?: string }> onMessage: (message: ChatMessage) => void busy: boolean } @@ -46,20 +34,12 @@ export function PersonalWechatChatComposer({ className, status, targetId, - isGroupChat, - initialMode = 'text', - initialImage = null, onOpenTextToSpeechSettings, onCancel, onSend, onMessage, busy }: PersonalWechatChatComposerProps): React.ReactElement { - const [mode, setMode] = useState(initialMode) - const [text, setText] = useState('') - const [image, setImage] = useState(initialImage) - const [voice, setVoice] = useState(null) - const [voiceSource, setVoiceSource] = useState('generated') const [voiceText, setVoiceText] = useState('') const [ttsSettings, setTtsSettings] = useState(null) const [ttsVoices, setTtsVoices] = useState([]) @@ -71,12 +51,6 @@ export function PersonalWechatChatComposer({ const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null) const generatedVoiceRef = useRef(null) const generatedAudioRef = useRef(null) - const selectedTypeReady = - mode === 'text' - ? status.canSendText - : mode === 'image' - ? status.canSendImage - : status.canSendVoice const isBusy = busy || isGenerating const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId) @@ -108,61 +82,17 @@ export function PersonalWechatChatComposer({ setIsPreviewPlaying(false) setPreviewCurrentTime(0) setPreviewDuration(0) - if (current?.filePath) + if (current?.filePath) { void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined) + } }, []) useEffect(() => () => clearGeneratedVoice(), [clearGeneratedVoice]) - const selectImage = async (): Promise => { - if (isBusy) return - const selection = await window.api.selectPersonalWechatImage() - if (!selection.canceled && selection.path) { - setImage({ - path: selection.path, - name: selection.name || selection.path.split(/[\\/]/).pop() || '图片' - }) - setFeedback(null) - } - } - - const selectVoice = async (): Promise => { - if (isBusy) return - const selection = await window.api.selectPersonalWechatVoice() - if (!selection.canceled && selection.path) { - setVoice({ - path: selection.path, - name: selection.name || selection.path.split(/[\\/]/).pop() || '语音' - }) - setFeedback(null) - } - } - - const send = async ( - request: PersonalWechatSendRequest, - displayText: string - ): Promise => { - if (isBusy || !selectedTypeReady) return false - setFeedback(null) - const result = await onSend(request, displayText) - setFeedback({ - success: result.success, - message: result.success ? '已发送' : result.error || '发送失败' - }) - if (result.success) - onMessage({ - id: `${Date.now()}-${Math.random()}`, - type: request.type, - text: request.type === 'text' ? request.text : displayText, - fileName: request.type === 'text' ? undefined : displayText, - outgoing: true - }) - return result.success - } - const generateVoice = async (): Promise => { - if (isBusy || !ttsSettings?.hasApiKey || !ttsSettings.selectedVoiceId || !voiceText.trim()) + if (isBusy || !ttsSettings?.hasApiKey || !ttsSettings.selectedVoiceId || !voiceText.trim()) { return + } clearGeneratedVoice() setIsGenerating(true) setFeedback(null) @@ -188,244 +118,122 @@ export function PersonalWechatChatComposer({ } } - const sendCurrent = async (): Promise => { - if (mode === 'text') { - const value = text.trim() - if (value) - await send({ type: 'text', to: targetId, text: value, isGroup: isGroupChat }, value) - } else if (mode === 'image' && image) { - await send( - { type: 'image', to: targetId, filePath: image.path, isGroup: isGroupChat }, - image.name - ) - } else if (mode === 'voice' && voiceSource === 'file' && voice) { - await send( - { type: 'voice', to: targetId, filePath: voice.path, isGroup: isGroupChat }, - voice.name - ) + const sendGenerated = async (): Promise => { + const current = generatedVoiceRef.current + if (!current || isBusy || !status.canSendVoice) return + setFeedback(null) + const displayText = voiceText.trim() || '语音消息' + const result = await onSend(current.filePath, displayText) + setFeedback({ + success: result.success, + message: result.success + ? '发送成功, 可使用另一设备查看发送的消息' + : result.error || '发送失败' + }) + if (result.success) { + onMessage({ + id: `${Date.now()}-${Math.random()}`, + type: 'voice', + text: displayText, + fileName: displayText, + outgoing: true + }) } } - const sendGenerated = async (): Promise => { - if (!generatedVoiceRef.current) return - await send( - { - type: 'voice', - to: targetId, - filePath: generatedVoiceRef.current.filePath, - isGroup: isGroupChat - }, - voiceText.trim() || '语音消息' - ) - } - - const sendGeneratedAlternative = async (): Promise => { - if (!generatedVoiceRef.current) return - await send( - { - type: 'voice', - to: targetId, - filePath: generatedVoiceRef.current.filePath, - isGroup: isGroupChat, - voiceSendMode: 'legacy' - }, - voiceText.trim() || '语音消息' - ) - } - - const canSend = Boolean( - selectedTypeReady && - !isBusy && - ((mode === 'text' && text.trim()) || - (mode === 'image' && image) || - (mode === 'voice' && - ((voiceSource === 'file' && voice) || (voiceSource === 'generated' && generatedVoice)))) - ) const generatedReady = Boolean( ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim() ) + const canSend = Boolean(targetId && status.canSendVoice && !isBusy && generatedVoice) const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0 return (
- { - clearGeneratedVoice() - setMode(value as PersonalWechatComposerMode) - setFeedback(null) - }} - disabled={isBusy} - > - 文字 - 图片 - 语音 - - -
- {mode === 'text' && ( -
-