mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-08 15:43:22 +08:00
feat: 统一手动发送入口为文字转语音
- 移除普通文本、图片和本地语音的手动发送入口 - 保留文字转语音的生成、试听和发送能力
This commit is contained in:
+1
-5
@@ -13,15 +13,11 @@ resources/connectors/wechat/
|
|||||||
resources/connectors/wechat-personal/
|
resources/connectors/wechat-personal/
|
||||||
.omc
|
.omc
|
||||||
.codex/
|
.codex/
|
||||||
services/share-card-worker/.wrangler/
|
|
||||||
services/share-card-worker/wrangler.local.jsonc
|
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
docs/design/
|
|
||||||
docs/ui-redesign-plan.md
|
|
||||||
docs/ui-redesign-spec.md
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
findings.md
|
findings.md
|
||||||
progress.md
|
progress.md
|
||||||
task_plan.md
|
task_plan.md
|
||||||
.agents
|
.agents
|
||||||
*__screenshots__
|
*__screenshots__
|
||||||
|
.ai-local
|
||||||
+45
-38
@@ -16,7 +16,7 @@ import {
|
|||||||
dialog,
|
dialog,
|
||||||
protocol
|
protocol
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { basename, dirname, extname, join } from 'path'
|
import { dirname, extname, join } from 'path'
|
||||||
import { existsSync, promises as fsPromises } from 'fs'
|
import { existsSync, promises as fsPromises } from 'fs'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
@@ -113,12 +113,17 @@ import { installSafeConsole } from './safe-log'
|
|||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
||||||
import { wechatActionLogService } from './services/wechat-action-log-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 { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||||
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
||||||
import { scheduledReportService } from './services/scheduled-report-service'
|
import { scheduledReportService } from './services/scheduled-report-service'
|
||||||
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
|
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
|
||||||
import { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service'
|
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 {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
ScheduledReportCreateInput,
|
||||||
ScheduledReportUpdateInput
|
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 }) => {
|
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
@@ -1980,52 +1999,40 @@ app.whenReady().then(async () => {
|
|||||||
return error ? { success: false, error } : { success: true }
|
return error ? { success: false, error } : { success: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
|
ipcMain.handle('wechat-personal:rebind', () => personalWechatSendService.rebind())
|
||||||
ipcMain.handle('wechat-personal:send', async (_, request: PersonalWechatSendRequest) => {
|
ipcMain.handle(
|
||||||
if (request.type !== 'voice' || String(request.fromId || '').trim()) {
|
'wechat-personal:sendGeneratedTtsVoice',
|
||||||
return personalWechatSendService.send(request)
|
async (_, request: PersonalWechatGeneratedTtsVoiceRequest) => {
|
||||||
}
|
const to = String(request?.to || '').trim()
|
||||||
|
const filePath = String(request?.filePath || '').trim()
|
||||||
let fromId = ''
|
let fromId = ''
|
||||||
try {
|
try {
|
||||||
const self = await chat.getSelfAccountInfoAsync()
|
const self = await chat.getSelfAccountInfoAsync()
|
||||||
fromId = String(self?.wxid || '').trim()
|
fromId = String(self?.wxid || '').trim()
|
||||||
} catch {
|
} catch {
|
||||||
// The sender can still operate without an account wxid for non-voice messages.
|
// Windows 语音发送需要当前账号 wxid;Gateway 会继续规范化失败结果。
|
||||||
}
|
}
|
||||||
return personalWechatSendService.send(fromId ? { ...request, fromId } : request)
|
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 }
|
||||||
|
}
|
||||||
|
)
|
||||||
ipcMain.handle('wechat-personal:getVoiceDiagnostic', () =>
|
ipcMain.handle('wechat-personal:getVoiceDiagnostic', () =>
|
||||||
personalWechatSendService.getLatestVoiceDiagnostic()
|
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) => {
|
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
|
||||||
const window = BrowserWindow.fromWebContents(event.sender)
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
const result = await dialog.showOpenDialog(window!, {
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
|
|||||||
@@ -584,7 +584,19 @@ function toPersonalWechatSendRequest(request: WechatActionRequest): PersonalWech
|
|||||||
if (request.content.type === 'image') {
|
if (request.content.type === 'image') {
|
||||||
return { ...base, type: 'image', filePath: request.content.path }
|
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(
|
function contentAudit(
|
||||||
|
|||||||
Vendored
+5
-4
@@ -54,8 +54,8 @@ import type {
|
|||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatImageSelectionResult,
|
PersonalWechatGeneratedTtsVoiceRequest,
|
||||||
PersonalWechatVoiceSelectionResult,
|
PersonalWechatGeneratedTtsVoiceResult,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult,
|
PersonalWechatSendResult,
|
||||||
PersonalWechatSenderStatus,
|
PersonalWechatSenderStatus,
|
||||||
@@ -637,8 +637,9 @@ declare global {
|
|||||||
callback: (status: PersonalWechatRuntimeProgressEvent) => void
|
callback: (status: PersonalWechatRuntimeProgressEvent) => void
|
||||||
) => () => void
|
) => () => void
|
||||||
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
|
rebindPersonalWechatSender: () => Promise<PersonalWechatSenderStatus>
|
||||||
selectPersonalWechatImage: () => Promise<PersonalWechatImageSelectionResult>
|
sendGeneratedTtsVoice: (
|
||||||
selectPersonalWechatVoice: () => Promise<PersonalWechatVoiceSelectionResult>
|
request: PersonalWechatGeneratedTtsVoiceRequest
|
||||||
|
) => Promise<PersonalWechatGeneratedTtsVoiceResult>
|
||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
) => Promise<PersonalWechatSendResult>
|
) => Promise<PersonalWechatSendResult>
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import type {
|
|||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatImageSelectionResult,
|
PersonalWechatGeneratedTtsVoiceRequest,
|
||||||
PersonalWechatVoiceSelectionResult,
|
PersonalWechatGeneratedTtsVoiceResult,
|
||||||
PersonalWechatSendRequest,
|
PersonalWechatSendRequest,
|
||||||
PersonalWechatSendResult,
|
PersonalWechatSendResult,
|
||||||
PersonalWechatSenderStatus,
|
PersonalWechatSenderStatus,
|
||||||
@@ -438,10 +438,11 @@ const api = {
|
|||||||
},
|
},
|
||||||
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
|
rebindPersonalWechatSender: (): Promise<PersonalWechatSenderStatus> =>
|
||||||
ipcRenderer.invoke('wechat-personal:rebind'),
|
ipcRenderer.invoke('wechat-personal:rebind'),
|
||||||
selectPersonalWechatImage: (): Promise<PersonalWechatImageSelectionResult> =>
|
sendGeneratedTtsVoice: (
|
||||||
ipcRenderer.invoke('wechat-personal:selectImage'),
|
request: PersonalWechatGeneratedTtsVoiceRequest
|
||||||
selectPersonalWechatVoice: (): Promise<PersonalWechatVoiceSelectionResult> =>
|
): Promise<PersonalWechatGeneratedTtsVoiceResult> =>
|
||||||
ipcRenderer.invoke('wechat-personal:selectVoice'),
|
ipcRenderer.invoke('wechat-personal:sendGeneratedTtsVoice', request),
|
||||||
|
// 日报图片发送仍使用现有业务发送链路;普通聊天 Composer 不暴露此入口。
|
||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||||
|
|||||||
@@ -124,6 +124,25 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
return typeMatch && contentMatch
|
return typeMatch && contentMatch
|
||||||
})
|
})
|
||||||
}, [messages, contentFilter])
|
}, [messages, contentFilter])
|
||||||
|
|
||||||
|
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
|
||||||
|
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 <EmptyConversationState />
|
if (!contact) return <EmptyConversationState />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,7 +157,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onRefreshData={onRefreshData}
|
onRefreshData={onRefreshData}
|
||||||
onTestSend={() => setSendDialogOpen(true)}
|
onTestSend={() => void handleOpenPersonalWechatSend()}
|
||||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||||
/>
|
/>
|
||||||
<DataTrustBar messageCount={messages.length} />
|
<DataTrustBar messageCount={messages.length} />
|
||||||
|
|||||||
@@ -106,11 +106,12 @@ export function ChatHeader({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="chat-header-text-action"
|
className="chat-header-text-action"
|
||||||
aria-label="发送消息"
|
aria-label="文字转语音"
|
||||||
|
title="文字转语音"
|
||||||
onClick={onTestSend}
|
onClick={onTestSend}
|
||||||
>
|
>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
<span>发送消息</span>
|
<span>文字转语音</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -120,12 +121,13 @@ export function ChatHeader({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="chat-header-text-action"
|
className="chat-header-text-action"
|
||||||
aria-label="发送消息"
|
aria-label="文字转语音"
|
||||||
|
title="文字转语音"
|
||||||
onClick={onTestSend}
|
onClick={onTestSend}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
<span>发送消息</span>
|
<span>文字转语音</span>
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type {
|
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||||
PersonalWechatSendRequest,
|
|
||||||
PersonalWechatSenderStatus
|
|
||||||
} from '../../../../shared/personal-wechat'
|
|
||||||
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
|
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 }
|
type GeneratedVoice = { filePath: string; audioDataUrl: string }
|
||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
id: string
|
id: string
|
||||||
type: PersonalWechatComposerMode | 'system'
|
type: 'voice' | 'system'
|
||||||
text?: string
|
text?: string
|
||||||
fileName?: string
|
fileName?: string
|
||||||
outgoing: boolean
|
outgoing: boolean
|
||||||
@@ -23,15 +17,9 @@ interface PersonalWechatChatComposerProps {
|
|||||||
className?: string
|
className?: string
|
||||||
status: PersonalWechatSenderStatus
|
status: PersonalWechatSenderStatus
|
||||||
targetId: string
|
targetId: string
|
||||||
isGroupChat: boolean
|
|
||||||
initialMode?: PersonalWechatComposerMode
|
|
||||||
initialImage?: SelectedLocalFile | null
|
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onSend: (
|
onSend: (filePath: string, displayText: string) => Promise<{ success: boolean; error?: string }>
|
||||||
request: PersonalWechatSendRequest,
|
|
||||||
displayText: string
|
|
||||||
) => Promise<{ success: boolean; error?: string }>
|
|
||||||
onMessage: (message: ChatMessage) => void
|
onMessage: (message: ChatMessage) => void
|
||||||
busy: boolean
|
busy: boolean
|
||||||
}
|
}
|
||||||
@@ -46,20 +34,12 @@ export function PersonalWechatChatComposer({
|
|||||||
className,
|
className,
|
||||||
status,
|
status,
|
||||||
targetId,
|
targetId,
|
||||||
isGroupChat,
|
|
||||||
initialMode = 'text',
|
|
||||||
initialImage = null,
|
|
||||||
onOpenTextToSpeechSettings,
|
onOpenTextToSpeechSettings,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSend,
|
onSend,
|
||||||
onMessage,
|
onMessage,
|
||||||
busy
|
busy
|
||||||
}: PersonalWechatChatComposerProps): React.ReactElement {
|
}: PersonalWechatChatComposerProps): React.ReactElement {
|
||||||
const [mode, setMode] = useState<PersonalWechatComposerMode>(initialMode)
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
const [image, setImage] = useState<SelectedLocalFile | null>(initialImage)
|
|
||||||
const [voice, setVoice] = useState<SelectedLocalFile | null>(null)
|
|
||||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>('generated')
|
|
||||||
const [voiceText, setVoiceText] = useState('')
|
const [voiceText, setVoiceText] = useState('')
|
||||||
const [ttsSettings, setTtsSettings] = useState<TextToSpeechSettings | null>(null)
|
const [ttsSettings, setTtsSettings] = useState<TextToSpeechSettings | null>(null)
|
||||||
const [ttsVoices, setTtsVoices] = useState<TextToSpeechVoice[]>([])
|
const [ttsVoices, setTtsVoices] = useState<TextToSpeechVoice[]>([])
|
||||||
@@ -71,12 +51,6 @@ export function PersonalWechatChatComposer({
|
|||||||
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
|
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
|
const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
|
||||||
const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
|
const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
const selectedTypeReady =
|
|
||||||
mode === 'text'
|
|
||||||
? status.canSendText
|
|
||||||
: mode === 'image'
|
|
||||||
? status.canSendImage
|
|
||||||
: status.canSendVoice
|
|
||||||
const isBusy = busy || isGenerating
|
const isBusy = busy || isGenerating
|
||||||
const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId)
|
const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId)
|
||||||
|
|
||||||
@@ -108,61 +82,17 @@ export function PersonalWechatChatComposer({
|
|||||||
setIsPreviewPlaying(false)
|
setIsPreviewPlaying(false)
|
||||||
setPreviewCurrentTime(0)
|
setPreviewCurrentTime(0)
|
||||||
setPreviewDuration(0)
|
setPreviewDuration(0)
|
||||||
if (current?.filePath)
|
if (current?.filePath) {
|
||||||
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => () => clearGeneratedVoice(), [clearGeneratedVoice])
|
useEffect(() => () => clearGeneratedVoice(), [clearGeneratedVoice])
|
||||||
|
|
||||||
const selectImage = async (): Promise<void> => {
|
|
||||||
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<void> => {
|
|
||||||
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<boolean> => {
|
|
||||||
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<void> => {
|
const generateVoice = async (): Promise<void> => {
|
||||||
if (isBusy || !ttsSettings?.hasApiKey || !ttsSettings.selectedVoiceId || !voiceText.trim())
|
if (isBusy || !ttsSettings?.hasApiKey || !ttsSettings.selectedVoiceId || !voiceText.trim()) {
|
||||||
return
|
return
|
||||||
|
}
|
||||||
clearGeneratedVoice()
|
clearGeneratedVoice()
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
setFeedback(null)
|
setFeedback(null)
|
||||||
@@ -188,142 +118,45 @@ export function PersonalWechatChatComposer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendCurrent = async (): Promise<void> => {
|
|
||||||
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<void> => {
|
const sendGenerated = async (): Promise<void> => {
|
||||||
if (!generatedVoiceRef.current) return
|
const current = generatedVoiceRef.current
|
||||||
await send(
|
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',
|
type: 'voice',
|
||||||
to: targetId,
|
text: displayText,
|
||||||
filePath: generatedVoiceRef.current.filePath,
|
fileName: displayText,
|
||||||
isGroup: isGroupChat
|
outgoing: true
|
||||||
},
|
})
|
||||||
voiceText.trim() || '语音消息'
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendGeneratedAlternative = async (): Promise<void> => {
|
|
||||||
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(
|
const generatedReady = Boolean(
|
||||||
ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim()
|
ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim()
|
||||||
)
|
)
|
||||||
|
const canSend = Boolean(targetId && status.canSendVoice && !isBusy && generatedVoice)
|
||||||
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
|
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`personal-wechat-composer${className ? ` ${className}` : ''}`}
|
className={`personal-wechat-composer${className ? ` ${className}` : ''}`}
|
||||||
aria-label="发送消息"
|
aria-label="文字转语音"
|
||||||
>
|
>
|
||||||
<SegmentedControl
|
|
||||||
className="personal-wechat-composer-mode"
|
|
||||||
aria-label="消息类型"
|
|
||||||
value={mode}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
clearGeneratedVoice()
|
|
||||||
setMode(value as PersonalWechatComposerMode)
|
|
||||||
setFeedback(null)
|
|
||||||
}}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<SegmentedControlItem value="text">文字</SegmentedControlItem>
|
|
||||||
<SegmentedControlItem value="image">图片</SegmentedControlItem>
|
|
||||||
<SegmentedControlItem value="voice">语音</SegmentedControlItem>
|
|
||||||
</SegmentedControl>
|
|
||||||
|
|
||||||
<div className="personal-wechat-composer-editor">
|
|
||||||
{mode === 'text' && (
|
|
||||||
<div className="personal-wechat-text-editor">
|
|
||||||
<Textarea
|
|
||||||
aria-label="消息内容"
|
|
||||||
placeholder="输入消息"
|
|
||||||
value={text}
|
|
||||||
maxLength={2000}
|
|
||||||
rows={3}
|
|
||||||
disabled={isBusy}
|
|
||||||
onChange={(event) => {
|
|
||||||
setText(event.target.value)
|
|
||||||
setFeedback(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{text.length} / 2000</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === 'image' && (
|
|
||||||
<div className="personal-wechat-file-editor">
|
|
||||||
<div>
|
|
||||||
<strong>{image?.name || '还没有选择图片'}</strong>
|
|
||||||
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void selectImage()}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
{image ? '重新选择' : '选择图片'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === 'voice' && (
|
|
||||||
<div className="personal-wechat-voice-editor">
|
<div className="personal-wechat-voice-editor">
|
||||||
<SegmentedControl
|
|
||||||
aria-label="语音来源"
|
|
||||||
value={voiceSource}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
clearGeneratedVoice()
|
|
||||||
setVoiceSource(value as VoiceSource)
|
|
||||||
setFeedback(null)
|
|
||||||
}}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<SegmentedControlItem value="generated">输入文字生成</SegmentedControlItem>
|
|
||||||
<SegmentedControlItem value="file">选择本地文件</SegmentedControlItem>
|
|
||||||
</SegmentedControl>
|
|
||||||
{voiceSource === 'generated' ? (
|
|
||||||
<>
|
|
||||||
<div className="personal-wechat-voice-heading">
|
<div className="personal-wechat-voice-heading">
|
||||||
<span>{selectedTtsVoice?.name || '尚未选择音色'}</span>
|
<span>{selectedTtsVoice?.name || '尚未选择音色'}</span>
|
||||||
{onOpenTextToSpeechSettings && (
|
{onOpenTextToSpeechSettings && (
|
||||||
<Button variant="link" size="sm" onClick={onOpenTextToSpeechSettings}>
|
<Button variant="link" size="sm" onClick={onOpenTextToSpeechSettings} disabled={isBusy}>
|
||||||
语音设置
|
语音设置
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -333,7 +166,7 @@ export function PersonalWechatChatComposer({
|
|||||||
placeholder="输入要生成的文字"
|
placeholder="输入要生成的文字"
|
||||||
value={voiceText}
|
value={voiceText}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
rows={2}
|
rows={4}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
clearGeneratedVoice()
|
clearGeneratedVoice()
|
||||||
@@ -348,9 +181,7 @@ export function PersonalWechatChatComposer({
|
|||||||
src={generatedVoice.audioDataUrl}
|
src={generatedVoice.audioDataUrl}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
|
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
|
||||||
onTimeUpdate={(event) =>
|
onTimeUpdate={(event) => setPreviewCurrentTime(event.currentTarget.currentTime)}
|
||||||
setPreviewCurrentTime(event.currentTarget.currentTime)
|
|
||||||
}
|
|
||||||
onPause={() => setIsPreviewPlaying(false)}
|
onPause={() => setIsPreviewPlaying(false)}
|
||||||
onPlay={() => setIsPreviewPlaying(true)}
|
onPlay={() => setIsPreviewPlaying(true)}
|
||||||
onEnded={() => {
|
onEnded={() => {
|
||||||
@@ -385,17 +216,13 @@ export function PersonalWechatChatComposer({
|
|||||||
>
|
>
|
||||||
{isPreviewPlaying ? '暂停' : '试听'}
|
{isPreviewPlaying ? '暂停' : '试听'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
|
|
||||||
{busy ? '发送中…' : '发送'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void sendGeneratedAlternative()}
|
onClick={() => void sendGenerated()}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
title="使用另一条语音发送路径"
|
aria-label="发送到微信"
|
||||||
>
|
>
|
||||||
{busy ? '发送中…' : '空白语音?重新处理'}
|
{busy ? '发送中…' : '发送到微信'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,25 +235,6 @@ export function PersonalWechatChatComposer({
|
|||||||
{isGenerating ? '正在生成…' : '生成语音'}
|
{isGenerating ? '正在生成…' : '生成语音'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="personal-wechat-file-editor">
|
|
||||||
<div>
|
|
||||||
<strong>{voice?.name || '还没有选择语音文件'}</strong>
|
|
||||||
<small>{voice ? voice.path : '支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC'}</small>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void selectVoice()}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
{voice ? '重新选择' : '选择语音'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="personal-wechat-composer-footer">
|
<div className="personal-wechat-composer-footer">
|
||||||
@@ -436,11 +244,6 @@ export function PersonalWechatChatComposer({
|
|||||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={isBusy}>
|
<Button variant="outline" size="sm" onClick={onCancel} disabled={isBusy}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
{!(mode === 'voice' && voiceSource === 'generated' && generatedVoice) && (
|
|
||||||
<Button size="sm" onClick={() => void sendCurrent()} disabled={!canSend}>
|
|
||||||
{busy ? '正在发送…' : '发送消息'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import {
|
|||||||
import { isMac, isWindows } from '../../utils/runtime-environment'
|
import { isMac, isWindows } from '../../utils/runtime-environment'
|
||||||
import {
|
import {
|
||||||
PersonalWechatChatComposer,
|
PersonalWechatChatComposer,
|
||||||
type ChatMessage,
|
type ChatMessage
|
||||||
type PersonalWechatComposerMode
|
|
||||||
} from './PersonalWechatChatComposer'
|
} from './PersonalWechatChatComposer'
|
||||||
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
||||||
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog'
|
||||||
@@ -36,7 +35,7 @@ export interface PersonalWechatSendDialogProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
onOpenPersonalWechatSettings?: () => void
|
onOpenPersonalWechatSettings?: () => void
|
||||||
initialMode?: PersonalWechatComposerMode
|
initialMode?: 'text' | 'image' | 'voice'
|
||||||
initialImage?: SelectedLocalFile | null
|
initialImage?: SelectedLocalFile | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +75,6 @@ function PersonalWechatMacSendDialog({
|
|||||||
isGroupChat,
|
isGroupChat,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenTextToSpeechSettings,
|
onOpenTextToSpeechSettings,
|
||||||
initialMode = 'text',
|
|
||||||
initialImage = null
|
initialImage = null
|
||||||
}: PersonalWechatSendDialogProps): React.ReactElement {
|
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||||
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||||
@@ -89,28 +87,20 @@ function PersonalWechatMacSendDialog({
|
|||||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
||||||
const [sendBusy, setSendBusy] = useState(false)
|
const [sendBusy, setSendBusy] = useState(false)
|
||||||
const [detectionAttempted, setDetectionAttempted] = useState(false)
|
const [detectionAttempted, setDetectionAttempted] = useState(false)
|
||||||
// Status can be reconstructed from a previous OneBot process/log. These
|
// 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。
|
||||||
// session gates ensure the user explicitly binds and detects after opening
|
|
||||||
// the flow instead of inheriting stale readiness.
|
|
||||||
const [sessionBound, setSessionBound] = useState(false)
|
const [sessionBound, setSessionBound] = useState(false)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [sendError, setSendError] = useState<string | null>(null)
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
const [voiceDiagnostic, setVoiceDiagnostic] = useState<PersonalWechatVoiceDiagnostic | null>(null)
|
||||||
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
const [voiceDiagnosticOpen, setVoiceDiagnosticOpen] = useState(false)
|
||||||
const [keepOneBotProcess, setKeepOneBotProcess] = useState(false)
|
const [keepOneBotProcess, setKeepOneBotProcess] = useState(false)
|
||||||
const [composerStarted, setComposerStarted] = useState(Boolean(initialImage))
|
|
||||||
const requestIdRef = useRef(0)
|
const requestIdRef = useRef(0)
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||||
const closingRef = useRef(false)
|
const closingRef = useRef(false)
|
||||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
const targetId = contact.m_nsUsrName
|
const targetId = contact.m_nsUsrName
|
||||||
const isBusy = binding || runtimeBusy || sendBusy
|
const isBusy = binding || runtimeBusy || sendBusy
|
||||||
const setupReady = Boolean(
|
const setupReady = Boolean(initialImage ? senderStatus?.canSendImage : senderStatus?.canSendVoice)
|
||||||
sessionBound &&
|
|
||||||
detectionAttempted &&
|
|
||||||
senderStatus?.canSendText &&
|
|
||||||
(senderStatus?.canSendImage || senderStatus?.canSendVoice)
|
|
||||||
)
|
|
||||||
|
|
||||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
const requestId = ++requestIdRef.current
|
const requestId = ++requestIdRef.current
|
||||||
@@ -246,15 +236,25 @@ function PersonalWechatMacSendDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async (
|
const handleSend = async (
|
||||||
request: PersonalWechatSendRequest
|
filePath: string
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
try {
|
try {
|
||||||
const response = await window.api.sendPersonalWechatMessage({ ...request, to: targetId })
|
const response = await window.api.sendGeneratedTtsVoice({
|
||||||
|
to: targetId,
|
||||||
|
isGroup: isGroupChat,
|
||||||
|
filePath
|
||||||
|
})
|
||||||
setSenderStatus(response.status)
|
setSenderStatus(response.status)
|
||||||
if (!response.success) setSendError(response.error || '发送失败')
|
const success = response.action.status === 'sent'
|
||||||
return { success: response.success, error: response.error }
|
const error = success
|
||||||
|
? undefined
|
||||||
|
: response.action.errorCode === 'SEND_CAPABILITY_UNAVAILABLE'
|
||||||
|
? '当前微信发送能力不可用'
|
||||||
|
: response.action.reason || '发送失败,请重试'
|
||||||
|
if (error) setSendError(error)
|
||||||
|
return { success, error }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
setSendError(message)
|
setSendError(message)
|
||||||
@@ -264,6 +264,39 @@ function PersonalWechatMacSendDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSendReportImage = async (): Promise<void> => {
|
||||||
|
if (!initialImage || !senderStatus?.canSendImage || sendBusy) return
|
||||||
|
setSendBusy(true)
|
||||||
|
setSendError(null)
|
||||||
|
try {
|
||||||
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
|
type: 'image',
|
||||||
|
to: targetId,
|
||||||
|
isGroup: isGroupChat,
|
||||||
|
filePath: initialImage.path
|
||||||
|
} satisfies PersonalWechatSendRequest)
|
||||||
|
setSenderStatus(response.status)
|
||||||
|
if (!response.success) {
|
||||||
|
setSendError(response.error || '日报图片发送失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMessages((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `${Date.now()}-${Math.random()}`,
|
||||||
|
type: 'system',
|
||||||
|
text: initialImage.name,
|
||||||
|
fileName: initialImage.name,
|
||||||
|
outgoing: true
|
||||||
|
}
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
setSendError(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setSendBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenVoiceDiagnostic = async (): Promise<void> => {
|
const handleOpenVoiceDiagnostic = async (): Promise<void> => {
|
||||||
if (!isMac) return
|
if (!isMac) return
|
||||||
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
|
const diagnostic = await window.api.getPersonalWechatVoiceDiagnostic()
|
||||||
@@ -288,9 +321,9 @@ function PersonalWechatMacSendDialog({
|
|||||||
{displayName.slice(0, 1)}
|
{displayName.slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="personal-wechat-chat-heading">
|
<div className="personal-wechat-chat-heading">
|
||||||
<DialogTitle>{displayName}</DialogTitle>
|
<DialogTitle>文字转语音</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
|
发送给 {displayName} · {setupReady ? '微信已连接' : '配置微信发送能力'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -300,7 +333,7 @@ function PersonalWechatMacSendDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="personal-wechat-chat-body">
|
<div className="personal-wechat-chat-body">
|
||||||
{setupReady && composerStarted && (
|
{setupReady && !initialImage && (
|
||||||
<div className="personal-wechat-message-list" aria-label="消息列表">
|
<div className="personal-wechat-message-list" aria-label="消息列表">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="personal-wechat-empty-message">还没有发送消息。</div>
|
<div className="personal-wechat-empty-message">还没有发送消息。</div>
|
||||||
@@ -310,13 +343,7 @@ function PersonalWechatMacSendDialog({
|
|||||||
key={message.id}
|
key={message.id}
|
||||||
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="personal-wechat-message-kind">
|
<span className="personal-wechat-message-kind">语音</span>
|
||||||
{message.type === 'text'
|
|
||||||
? '文字'
|
|
||||||
: message.type === 'image'
|
|
||||||
? '图片'
|
|
||||||
: '语音'}
|
|
||||||
</span>
|
|
||||||
<span>{message.text || message.fileName}</span>
|
<span>{message.text || message.fileName}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -324,7 +351,7 @@ function PersonalWechatMacSendDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(!setupReady || !composerStarted) && senderStatus && (
|
{!setupReady && senderStatus && (
|
||||||
<PersonalWechatSetupGuide
|
<PersonalWechatSetupGuide
|
||||||
runtimeStatus={runtimeStatus}
|
runtimeStatus={runtimeStatus}
|
||||||
senderStatus={senderStatus}
|
senderStatus={senderStatus}
|
||||||
@@ -337,18 +364,24 @@ function PersonalWechatMacSendDialog({
|
|||||||
onBind={() => void handleBind()}
|
onBind={() => void handleBind()}
|
||||||
detectionAttempted={detectionAttempted}
|
detectionAttempted={detectionAttempted}
|
||||||
onDetect={() => void handleDetect()}
|
onDetect={() => void handleDetect()}
|
||||||
onStartSending={() => setComposerStarted(true)}
|
onStartSending={() => undefined}
|
||||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{setupReady && composerStarted && (
|
{setupReady && initialImage && (
|
||||||
|
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
||||||
|
<p>已准备日报图片:{initialImage.name}</p>
|
||||||
|
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
||||||
|
{sendBusy ? '发送中…' : '发送日报图片'}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupReady && !initialImage && (
|
||||||
<PersonalWechatChatComposer
|
<PersonalWechatChatComposer
|
||||||
status={senderStatus!}
|
status={senderStatus!}
|
||||||
targetId={targetId}
|
targetId={targetId}
|
||||||
isGroupChat={isGroupChat}
|
|
||||||
initialMode={initialMode}
|
|
||||||
initialImage={initialImage}
|
|
||||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
@@ -362,7 +395,7 @@ function PersonalWechatMacSendDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{setupReady && composerStarted && isMac && (
|
{setupReady && isMac && (
|
||||||
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
<div className="personal-wechat-chat-footer flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>保留 OneBot 进程</span>
|
<span>保留 OneBot 进程</span>
|
||||||
@@ -377,7 +410,7 @@ function PersonalWechatMacSendDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!setupReady || !composerStarted) && (
|
{!setupReady && (
|
||||||
<div
|
<div
|
||||||
className={`personal-wechat-chat-footer flex items-center ${isMac ? 'justify-between' : 'justify-end'}`}
|
className={`personal-wechat-chat-footer flex items-center ${isMac ? 'justify-between' : 'justify-end'}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -77,13 +77,9 @@ export function PersonalWechatSetupGuide({
|
|||||||
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
||||||
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
||||||
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false)
|
||||||
const canSendText = Boolean(senderStatus?.canSendText)
|
|
||||||
const canSendImage = Boolean(senderStatus?.canSendImage)
|
|
||||||
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
||||||
const mediaReady = canSendImage || canSendVoice
|
const detectedVoiceReady = detectionAttempted && canSendVoice
|
||||||
const detectedTextReady = detectionAttempted && canSendText
|
const verificationComplete = connected && detectedVoiceReady
|
||||||
const detectedMediaReady = detectionAttempted && mediaReady
|
|
||||||
const verificationComplete = connected && detectedTextReady && detectedMediaReady
|
|
||||||
const allReady = verificationComplete
|
const allReady = verificationComplete
|
||||||
const progress = runtimeProgress || runtimeStatus
|
const progress = runtimeProgress || runtimeStatus
|
||||||
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
|
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
|
||||||
@@ -94,7 +90,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
<div>
|
<div>
|
||||||
<span className="personal-wechat-eyebrow">首次使用</span>
|
<span className="personal-wechat-eyebrow">首次使用</span>
|
||||||
<h2>先准备好微信消息功能</h2>
|
<h2>先准备好微信消息功能</h2>
|
||||||
<p>只需几步,TraceMemo 就能像普通聊天窗口一样发送文字、图片和语音。</p>
|
<p>只需几步,TraceMemo 就能在当前会话中生成并发送语音。</p>
|
||||||
</div>
|
</div>
|
||||||
{allReady && <span className="personal-wechat-setup-complete">已完成</span>}
|
{allReady && <span className="personal-wechat-setup-complete">已完成</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -191,14 +187,8 @@ export function PersonalWechatSetupGuide({
|
|||||||
<span className="personal-wechat-step-number">{verificationComplete ? '✓' : '3'}</span>
|
<span className="personal-wechat-step-number">{verificationComplete ? '✓' : '3'}</span>
|
||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>验证消息能力</strong>
|
<strong>验证消息能力</strong>
|
||||||
<p>
|
<p>请打开微信,可以给任意好友发送一张图片,完成一次能力初始化。</p>
|
||||||
请打开手机微信,给任意好友手动发送:
|
<p className="personal-wechat-step-hint">完成后点击“重新检测”。</p>
|
||||||
<br />① 一条文字消息
|
|
||||||
<br />② 一张图片
|
|
||||||
</p>
|
|
||||||
<p className="personal-wechat-step-hint">
|
|
||||||
TraceMemo 需要通过你主动发送的消息初始化微信消息能力。完成后点击“重新检测”。
|
|
||||||
</p>
|
|
||||||
{connected && !verificationComplete && (
|
{connected && !verificationComplete && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -211,7 +201,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
)}
|
)}
|
||||||
{detectionAttempted && !detecting && !verificationComplete && (
|
{detectionAttempted && !detecting && !verificationComplete && (
|
||||||
<p className="personal-wechat-step-hint" role="status">
|
<p className="personal-wechat-step-hint" role="status">
|
||||||
暂未检测到新的消息,请确认已在手机微信中发送文字和图片,然后再次检测。
|
暂未检测到语音发送能力,请确认微信已完成初始化后再次检测。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,10 +214,7 @@ export function PersonalWechatSetupGuide({
|
|||||||
<div className="personal-wechat-step-content">
|
<div className="personal-wechat-step-content">
|
||||||
<strong>能力检测</strong>
|
<strong>能力检测</strong>
|
||||||
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
|
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
|
||||||
{[
|
{[['语音消息', detectedVoiceReady]].map(([label, ready]) => (
|
||||||
['文字消息', detectedTextReady],
|
|
||||||
['图片和语音消息', detectedMediaReady]
|
|
||||||
].map(([label, ready]) => (
|
|
||||||
<span key={String(label)} className={ready ? 'is-ready' : ''}>
|
<span key={String(label)} className={ready ? 'is-ready' : ''}>
|
||||||
<b aria-hidden>{ready ? '✓' : '−'}</b>
|
<b aria-hidden>{ready ? '✓' : '−'}</b>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onOpenTextToSpeechSettings,
|
onOpenTextToSpeechSettings,
|
||||||
onOpenPersonalWechatSettings,
|
onOpenPersonalWechatSettings,
|
||||||
initialMode = 'text',
|
|
||||||
initialImage = null
|
initialImage = null
|
||||||
}: PersonalWechatSendDialogProps): React.ReactElement {
|
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||||
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||||
@@ -105,13 +104,23 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async (
|
const handleSend = async (
|
||||||
request: PersonalWechatSendRequest
|
filePath: string
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
setSendBusy(true)
|
setSendBusy(true)
|
||||||
try {
|
try {
|
||||||
const response = await window.api.sendPersonalWechatMessage({ ...request, to: targetId })
|
const response = await window.api.sendGeneratedTtsVoice({
|
||||||
|
to: targetId,
|
||||||
|
isGroup: isGroupChat,
|
||||||
|
filePath
|
||||||
|
})
|
||||||
setStatus(response.status)
|
setStatus(response.status)
|
||||||
return { success: response.success, error: response.error }
|
const success = response.action.status === 'sent'
|
||||||
|
const error = success
|
||||||
|
? undefined
|
||||||
|
: response.action.errorCode === 'SEND_CAPABILITY_UNAVAILABLE'
|
||||||
|
? '当前微信发送能力不可用'
|
||||||
|
: response.action.reason || '发送失败,请重试'
|
||||||
|
return { success, error }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
setStatus((current) => ({
|
setStatus((current) => ({
|
||||||
@@ -125,6 +134,33 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSendReportImage = async (): Promise<void> => {
|
||||||
|
if (!initialImage || !status?.canSendImage || sendBusy) return
|
||||||
|
setSendBusy(true)
|
||||||
|
try {
|
||||||
|
const response = await window.api.sendPersonalWechatMessage({
|
||||||
|
type: 'image',
|
||||||
|
to: targetId,
|
||||||
|
isGroup: isGroupChat,
|
||||||
|
filePath: initialImage.path
|
||||||
|
} satisfies PersonalWechatSendRequest)
|
||||||
|
setStatus(response.status)
|
||||||
|
if (!response.success) return
|
||||||
|
setMessages((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `${Date.now()}-${Math.random()}`,
|
||||||
|
type: 'system',
|
||||||
|
text: initialImage.name,
|
||||||
|
fileName: initialImage.name,
|
||||||
|
outgoing: true
|
||||||
|
}
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
setSendBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const statusTone = useMemo(() => {
|
const statusTone = useMemo(() => {
|
||||||
if (status?.state === 'online' && status.canSend) return 'ready'
|
if (status?.state === 'online' && status.canSend) return 'ready'
|
||||||
if (!status || status.state === 'checking' || status.state === 'starting') return 'checking'
|
if (!status || status.state === 'checking' || status.state === 'starting') return 'checking'
|
||||||
@@ -146,11 +182,11 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
|
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
|
||||||
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
|
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
|
||||||
个人微信发送
|
文字转语音
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
向当前微信联系人或群聊发送文字、图片和语音。
|
向当前微信联系人或群聊发送文字转语音。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -158,7 +194,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
<span aria-hidden>i</span>
|
<span aria-hidden>i</span>
|
||||||
<p>
|
<p>
|
||||||
<strong>请在其他设备确认发送结果</strong>
|
<strong>请在其他设备确认发送结果</strong>
|
||||||
语音、图片等消息请切换到手机、平板等其他设备确认是否送达。
|
语音消息请切换到手机、平板等其他设备确认是否送达。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,24 +244,23 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
key={message.id}
|
key={message.id}
|
||||||
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="personal-wechat-message-kind">
|
<span className="personal-wechat-message-kind">语音</span>
|
||||||
{message.type === 'text'
|
|
||||||
? '文字'
|
|
||||||
: message.type === 'image'
|
|
||||||
? '图片'
|
|
||||||
: '语音'}
|
|
||||||
</span>
|
|
||||||
<span>{message.text || message.fileName}</span>
|
<span>{message.text || message.fileName}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{initialImage && status.canSendImage ? (
|
||||||
|
<section className="personal-wechat-composer" aria-label="日报图片发送">
|
||||||
|
<p>已准备日报图片:{initialImage.name}</p>
|
||||||
|
<Button size="sm" onClick={() => void handleSendReportImage()} disabled={sendBusy}>
|
||||||
|
{sendBusy ? '发送中…' : '发送日报图片'}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
) : !initialImage ? (
|
||||||
<PersonalWechatChatComposer
|
<PersonalWechatChatComposer
|
||||||
status={status}
|
status={status}
|
||||||
targetId={targetId}
|
targetId={targetId}
|
||||||
isGroupChat={isGroupChat}
|
|
||||||
initialMode={initialMode}
|
|
||||||
initialImage={initialImage}
|
|
||||||
className="personal-wechat-windows-composer"
|
className="personal-wechat-windows-composer"
|
||||||
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
@@ -233,6 +268,7 @@ export function PersonalWechatWindowsSendDialog({
|
|||||||
onMessage={(message) => setMessages((current) => [...current, message])}
|
onMessage={(message) => setMessages((current) => [...current, message])}
|
||||||
busy={sendBusy}
|
busy={sendBusy}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -622,6 +622,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
text-align: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--wxex-text-primary);
|
color: var(--wxex-text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -1096,6 +1097,7 @@
|
|||||||
&.is-success {
|
&.is-success {
|
||||||
color: var(--wxex-success);
|
color: var(--wxex-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-error {
|
&.is-error {
|
||||||
color: var(--wxex-danger);
|
color: var(--wxex-danger);
|
||||||
}
|
}
|
||||||
@@ -1118,15 +1120,18 @@
|
|||||||
.personal-wechat-capabilities {
|
.personal-wechat-capabilities {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-wechat-chat-header,
|
.personal-wechat-chat-header,
|
||||||
.personal-wechat-message-list,
|
.personal-wechat-message-list,
|
||||||
.personal-wechat-setup,
|
.personal-wechat-setup,
|
||||||
.personal-wechat-composer {
|
.personal-wechat-composer {
|
||||||
padding-inline: 14px;
|
padding-inline: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-wechat-generated-result {
|
.personal-wechat-generated-result {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-wechat-composer-editor {
|
.personal-wechat-composer-editor {
|
||||||
height: 280px;
|
height: 280px;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
@@ -1550,6 +1555,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-bubble .image-content {
|
.image-bubble .image-content {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: min(260px, 46vw);
|
max-width: min(260px, 46vw);
|
||||||
@@ -1608,6 +1614,7 @@
|
|||||||
0% {
|
0% {
|
||||||
background-position: 200% 0;
|
background-position: 200% 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
@@ -1846,6 +1853,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|
||||||
.chat-content-search-count,
|
.chat-content-search-count,
|
||||||
.chat-header-text-action>span {
|
.chat-header-text-action>span {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -2432,7 +2440,8 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px), (max-height: 620px) {
|
@media (max-width: 900px),
|
||||||
|
(max-height: 620px) {
|
||||||
.database-login-brand {
|
.database-login-brand {
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
}
|
}
|
||||||
@@ -2503,6 +2512,7 @@
|
|||||||
code {
|
code {
|
||||||
color: var(--text-secondary, #68766f);
|
color: var(--text-secondary, #68766f);
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ export type PersonalWechatSendRequest =
|
|||||||
| PersonalWechatSendImageRequest
|
| PersonalWechatSendImageRequest
|
||||||
| PersonalWechatSendVoiceRequest
|
| PersonalWechatSendVoiceRequest
|
||||||
|
|
||||||
|
/** Renderer 请求发送刚刚生成的 TTS 语音;Action 元数据由主进程补齐。 */
|
||||||
|
export interface PersonalWechatGeneratedTtsVoiceRequest {
|
||||||
|
to: string
|
||||||
|
isGroup: boolean
|
||||||
|
filePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonalWechatGeneratedTtsVoiceResult {
|
||||||
|
action: import('./wechat-action').WechatActionResult
|
||||||
|
status: PersonalWechatSenderStatus
|
||||||
|
}
|
||||||
|
|
||||||
export interface PersonalWechatSendResult {
|
export interface PersonalWechatSendResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
status: PersonalWechatSenderStatus
|
status: PersonalWechatSenderStatus
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ describe('chat menus', () => {
|
|||||||
expect(searchInput).toHaveFocus()
|
expect(searchInput).toHaveFocus()
|
||||||
await user.type(searchInput, '测试')
|
await user.type(searchInput, '测试')
|
||||||
expect(onContentFilterChange).toHaveBeenCalledTimes(2)
|
expect(onContentFilterChange).toHaveBeenCalledTimes(2)
|
||||||
expect(screen.getByRole('button', { name: '发送消息' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: '文字转语音' })).toBeDisabled()
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '关闭搜索' }))
|
await user.click(screen.getByRole('button', { name: '关闭搜索' }))
|
||||||
expect(onContentFilterChange).toHaveBeenLastCalledWith('')
|
expect(onContentFilterChange).toHaveBeenLastCalledWith('')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -13,12 +13,9 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
|
|||||||
|
|
||||||
const getStatus = vi.fn()
|
const getStatus = vi.fn()
|
||||||
const getRuntimeStatus = vi.fn()
|
const getRuntimeStatus = vi.fn()
|
||||||
const downloadRuntime = vi.fn()
|
|
||||||
const onRuntimeProgress = vi.fn(() => vi.fn())
|
const onRuntimeProgress = vi.fn(() => vi.fn())
|
||||||
const rebind = vi.fn()
|
const rebind = vi.fn()
|
||||||
const selectImage = vi.fn()
|
const sendGeneratedTtsVoice = vi.fn()
|
||||||
const selectVoice = vi.fn()
|
|
||||||
const sendMessage = vi.fn()
|
|
||||||
const getTextToSpeechSettings = vi.fn()
|
const getTextToSpeechSettings = vi.fn()
|
||||||
const listTextToSpeechVoices = vi.fn()
|
const listTextToSpeechVoices = vi.fn()
|
||||||
const synthesizeTextToSpeech = vi.fn()
|
const synthesizeTextToSpeech = vi.fn()
|
||||||
@@ -32,6 +29,7 @@ const contact = {
|
|||||||
md5: 'fixture-md5',
|
md5: 'fixture-md5',
|
||||||
type: 'group' as const
|
type: 'group' as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyStatus = {
|
const readyStatus = {
|
||||||
state: 'online' as const,
|
state: 'online' as const,
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
@@ -59,6 +57,7 @@ const readyStatus = {
|
|||||||
canSendVoice: true,
|
canSendVoice: true,
|
||||||
message: '个人微信已绑定'
|
message: '个人微信已绑定'
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyRuntime = {
|
const readyRuntime = {
|
||||||
version: 'v0.0.18',
|
version: 'v0.0.18',
|
||||||
state: 'ready' as const,
|
state: 'ready' as const,
|
||||||
@@ -80,32 +79,19 @@ function renderDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startComposer(): Promise<void> {
|
async function startComposer(): Promise<void> {
|
||||||
await screen.findByText('绑定个人微信')
|
await screen.findByRole('textbox', { name: '语音文字' })
|
||||||
const bind = screen.queryByRole('button', { name: '绑定微信' })
|
|
||||||
if (bind) {
|
|
||||||
fireEvent.click(bind)
|
|
||||||
await screen.findByText('✓ 微信已绑定')
|
|
||||||
}
|
|
||||||
const detect = await screen.findByRole('button', { name: '重新检测' })
|
|
||||||
fireEvent.click(detect)
|
|
||||||
const start = await screen.findByRole('button', { name: '开始发送' })
|
|
||||||
fireEvent.click(start)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('PersonalWechatSendDialog', () => {
|
describe('PersonalWechatSendDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getStatus.mockReset().mockResolvedValue(readyStatus)
|
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||||
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
|
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
|
||||||
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
|
|
||||||
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
||||||
rebind.mockReset().mockResolvedValue(readyStatus)
|
rebind.mockReset().mockResolvedValue(readyStatus)
|
||||||
selectImage
|
sendGeneratedTtsVoice.mockReset().mockResolvedValue({
|
||||||
.mockReset()
|
action: { status: 'sent' },
|
||||||
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.png', name: 'test.png' })
|
status: readyStatus
|
||||||
selectVoice
|
})
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.silk', name: 'test.silk' })
|
|
||||||
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
|
|
||||||
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
settings: {
|
settings: {
|
||||||
@@ -121,16 +107,7 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
})
|
})
|
||||||
listTextToSpeechVoices.mockReset().mockResolvedValue({
|
listTextToSpeechVoices.mockReset().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
items: [
|
items: [{ id: 'fish-warm-female', name: '暖阳女声' }],
|
||||||
{
|
|
||||||
id: 'fish-warm-female',
|
|
||||||
name: '暖阳女声',
|
|
||||||
description: '自然温和',
|
|
||||||
tags: ['女声'],
|
|
||||||
languages: ['中文'],
|
|
||||||
source: 'fish-audio'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
total: 1,
|
total: 1,
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
pageSize: 24,
|
pageSize: 24,
|
||||||
@@ -149,12 +126,9 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
value: {
|
value: {
|
||||||
getPersonalWechatSenderStatus: getStatus,
|
getPersonalWechatSenderStatus: getStatus,
|
||||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
||||||
downloadPersonalWechatRuntime: downloadRuntime,
|
|
||||||
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
||||||
rebindPersonalWechatSender: rebind,
|
rebindPersonalWechatSender: rebind,
|
||||||
selectPersonalWechatImage: selectImage,
|
sendGeneratedTtsVoice,
|
||||||
selectPersonalWechatVoice: selectVoice,
|
|
||||||
sendPersonalWechatMessage: sendMessage,
|
|
||||||
getTextToSpeechSettings,
|
getTextToSpeechSettings,
|
||||||
listTextToSpeechVoices,
|
listTextToSpeechVoices,
|
||||||
synthesizeTextToSpeech,
|
synthesizeTextToSpeech,
|
||||||
@@ -165,257 +139,39 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a user-facing four-step setup and keeps diagnostics collapsed', async () => {
|
it('opens the TTS composer immediately when voice capability is ready', async () => {
|
||||||
getRuntimeStatus
|
|
||||||
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
|
|
||||||
.mockResolvedValue(readyRuntime)
|
|
||||||
getStatus.mockResolvedValue({
|
|
||||||
...readyStatus,
|
|
||||||
state: 'stopped',
|
|
||||||
runtimeReady: false,
|
|
||||||
canSend: false,
|
|
||||||
canSendText: false,
|
|
||||||
canSendImage: false,
|
|
||||||
canSendVoice: false,
|
|
||||||
message: '尚未绑定当前微信'
|
|
||||||
})
|
|
||||||
renderDialog()
|
|
||||||
expect(await screen.findByText('准备 OneBot 运行时')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: '下载运行时' })).toBeEnabled()
|
|
||||||
expect(screen.getByText('绑定个人微信')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('能力检测')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('图片和语音消息')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('note')).toHaveTextContent(
|
|
||||||
'绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。'
|
|
||||||
)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '查看支持的微信版本' }))
|
|
||||||
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
|
|
||||||
expect(versionsDialog).toBeInTheDocument()
|
|
||||||
expect(versionsDialog).toHaveTextContent('4.1.11.53')
|
|
||||||
expect(screen.queryByLabelText('消息列表')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('TraceMemo 消息发送')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('PID 4668')).not.toBeVisible()
|
|
||||||
fireEvent.click(screen.getByText('高级诊断'))
|
|
||||||
expect(screen.getByText('PID 4668')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sends text through the existing message API and echoes it in the chat', async () => {
|
|
||||||
renderDialog()
|
renderDialog()
|
||||||
await startComposer()
|
await startComposer()
|
||||||
await userEvent
|
expect(screen.getByRole('dialog')).toHaveTextContent('文字转语音')
|
||||||
.setup()
|
expect(screen.queryByText('验证消息能力')).not.toBeInTheDocument()
|
||||||
.type(screen.getByRole('textbox', { name: '消息内容' }), '你好 TraceMemo')
|
expect(screen.getByRole('button', { name: '生成语音' })).toBeDisabled()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(sendMessage).toHaveBeenCalledWith({
|
|
||||||
type: 'text',
|
|
||||||
to: 'fixture-room@chatroom',
|
|
||||||
text: '你好 TraceMemo',
|
|
||||||
isGroup: true
|
|
||||||
})
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
screen.getByLabelText('消息列表').querySelector('.personal-wechat-message-bubble')
|
|
||||||
).toHaveTextContent('你好 TraceMemo')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('supports local image and voice selection', async () => {
|
it('generates, previews and sends a voice through the semantic TTS IPC', async () => {
|
||||||
renderDialog()
|
|
||||||
await startComposer()
|
|
||||||
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
|
|
||||||
expect(await screen.findByText('test.png')).toBeInTheDocument()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(sendMessage).toHaveBeenCalledWith({
|
|
||||||
type: 'image',
|
|
||||||
to: 'fixture-room@chatroom',
|
|
||||||
filePath: '/Users/fixture/test.png',
|
|
||||||
isGroup: true
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
|
||||||
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
|
|
||||||
expect(await screen.findByText('test.silk')).toBeInTheDocument()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(sendMessage).toHaveBeenCalledWith({
|
|
||||||
type: 'voice',
|
|
||||||
to: 'fixture-room@chatroom',
|
|
||||||
filePath: '/Users/fixture/test.silk',
|
|
||||||
isGroup: true
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sends the same generated voice through the comparison path', async () => {
|
|
||||||
renderDialog()
|
|
||||||
await startComposer()
|
|
||||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
|
||||||
fireEvent.change(screen.getByRole('textbox', { name: '语音文字' }), {
|
|
||||||
target: { value: '入口2语音测试' }
|
|
||||||
})
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '生成语音' }))
|
|
||||||
await waitFor(() => expect(screen.getByText('语音已生成')).toBeInTheDocument())
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '空白语音?重新处理' }))
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(sendMessage).toHaveBeenCalledWith({
|
|
||||||
type: 'voice',
|
|
||||||
to: 'fixture-room@chatroom',
|
|
||||||
filePath: '/tmp/generated.mp3',
|
|
||||||
isGroup: true,
|
|
||||||
voiceSendMode: 'legacy'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses the existing runtime, binding and detection IPC actions', async () => {
|
|
||||||
getRuntimeStatus
|
|
||||||
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
|
|
||||||
.mockResolvedValue(readyRuntime)
|
|
||||||
getStatus
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
...readyStatus,
|
|
||||||
state: 'stopped',
|
|
||||||
runtimeReady: false,
|
|
||||||
canSend: false,
|
|
||||||
canSendText: false,
|
|
||||||
canSendImage: false,
|
|
||||||
canSendVoice: false
|
|
||||||
})
|
|
||||||
.mockResolvedValue({
|
|
||||||
...readyStatus,
|
|
||||||
state: 'stopped',
|
|
||||||
runtimeReady: true,
|
|
||||||
canSend: false,
|
|
||||||
canSendText: false,
|
|
||||||
canSendImage: false,
|
|
||||||
canSendVoice: false
|
|
||||||
})
|
|
||||||
renderDialog()
|
|
||||||
await screen.findByRole('button', { name: '下载运行时' })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下载运行时' }))
|
|
||||||
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
|
|
||||||
await waitFor(() => expect(rebind).toHaveBeenCalledOnce())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a stable bound state after binding', async () => {
|
|
||||||
getRuntimeStatus.mockResolvedValue(readyRuntime)
|
|
||||||
getStatus
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
...readyStatus,
|
|
||||||
state: 'stopped',
|
|
||||||
canSend: false,
|
|
||||||
canSendText: false,
|
|
||||||
canSendImage: false,
|
|
||||||
canSendVoice: false
|
|
||||||
})
|
|
||||||
.mockResolvedValue(readyStatus)
|
|
||||||
renderDialog()
|
|
||||||
await screen.findByRole('button', { name: '绑定微信' })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
|
|
||||||
expect(await screen.findByText('✓ 微信已绑定')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('button', { name: '绑定微信' })).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats image and voice readiness as one media capability', async () => {
|
|
||||||
getStatus.mockResolvedValue({
|
|
||||||
...readyStatus,
|
|
||||||
canSendImage: false,
|
|
||||||
canSendVoice: true
|
|
||||||
})
|
|
||||||
renderDialog()
|
|
||||||
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
|
|
||||||
const bind = screen.queryByRole('button', { name: '绑定微信' })
|
|
||||||
if (bind) {
|
|
||||||
fireEvent.click(bind)
|
|
||||||
await screen.findByText('✓ 微信已绑定')
|
|
||||||
}
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '重新检测' }))
|
|
||||||
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
|
|
||||||
expect(screen.queryByText('图片消息')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('语音消息')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps re-detection available and explains when no new messages are found', async () => {
|
|
||||||
getRuntimeStatus.mockResolvedValue(readyRuntime)
|
|
||||||
getStatus.mockResolvedValue({
|
|
||||||
...readyStatus,
|
|
||||||
canSend: false,
|
|
||||||
canSendText: false,
|
|
||||||
canSendImage: false,
|
|
||||||
canSendVoice: false,
|
|
||||||
message: '等待消息初始化'
|
|
||||||
})
|
|
||||||
renderDialog()
|
|
||||||
await screen.findByText('绑定个人微信')
|
|
||||||
const bind = screen.queryByRole('button', { name: '绑定微信' })
|
|
||||||
if (bind) {
|
|
||||||
fireEvent.click(bind)
|
|
||||||
await screen.findByText('✓ 微信已绑定')
|
|
||||||
}
|
|
||||||
const detect = await screen.findByRole('button', { name: '重新检测' })
|
|
||||||
expect(detect).toBeEnabled()
|
|
||||||
fireEvent.click(detect)
|
|
||||||
expect(
|
|
||||||
await screen.findByText(
|
|
||||||
'暂未检测到新的消息,请确认已在手机微信中发送文字和图片,然后再次检测。'
|
|
||||||
)
|
|
||||||
).toBeInTheDocument()
|
|
||||||
expect(screen.getAllByText('未检测').length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the dialog and controls stable while sending', async () => {
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
sendMessage.mockImplementation(() => new Promise(() => undefined))
|
|
||||||
renderDialog()
|
renderDialog()
|
||||||
await startComposer()
|
await startComposer()
|
||||||
await screen.findByRole('textbox', { name: '消息内容' })
|
await user.type(screen.getByRole('textbox', { name: '语音文字' }), '你好 TraceMemo')
|
||||||
await user.type(screen.getByRole('textbox', { name: '消息内容' }), '发送中')
|
await user.click(screen.getByRole('button', { name: '生成语音' }))
|
||||||
await user.click(screen.getByRole('button', { name: '发送消息' }))
|
expect(await screen.findByText('语音已生成')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: '试听' })).toBeEnabled()
|
||||||
expect(screen.getByRole('radio', { name: '图片' })).toBeDisabled()
|
await user.click(screen.getByRole('button', { name: '发送到微信' }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sendGeneratedTtsVoice).toHaveBeenCalledWith({
|
||||||
|
to: 'fixture-room@chatroom',
|
||||||
|
isGroup: true,
|
||||||
|
filePath: '/tmp/generated.mp3'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('消息列表')).toHaveTextContent('你好 TraceMemo')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows and copies the latest redacted voice diagnostic JSON', async () => {
|
it('keeps the setup guide voice-only when voice capability is unavailable', async () => {
|
||||||
getPersonalWechatVoiceDiagnostic.mockResolvedValue({
|
getStatus.mockResolvedValue({ ...readyStatus, canSend: false, canSendVoice: false })
|
||||||
request_id: 'request-1',
|
renderDialog({ onOpenPersonalWechatSettings: vi.fn() })
|
||||||
voice_id: 'request-1',
|
expect(await screen.findByText('验证消息能力')).toBeInTheDocument()
|
||||||
phase: 'completed',
|
expect(screen.getByText('语音消息')).toBeInTheDocument()
|
||||||
encoder_name: 'go-silk',
|
expect(screen.queryByText('文字消息')).not.toBeInTheDocument()
|
||||||
encoder_version: 'wechat_chatter-v0.0.18',
|
expect(screen.queryByText('图片和语音消息')).not.toBeInTheDocument()
|
||||||
input_bytes: 35107,
|
|
||||||
normalized_input_bytes: 69804,
|
|
||||||
pcm_size: 69760,
|
|
||||||
sample_rate: 16000,
|
|
||||||
channels: 1,
|
|
||||||
input_duration_ms: 2180,
|
|
||||||
upload_result: '0',
|
|
||||||
upload_data_len: 4380,
|
|
||||||
silk_duration_ms: 2180,
|
|
||||||
send_result: '1'
|
|
||||||
})
|
|
||||||
renderDialog()
|
|
||||||
await startComposer()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '语音发送诊断' }))
|
|
||||||
const diagnosticDialog = await screen.findByRole('dialog', { name: '语音发送诊断' })
|
|
||||||
expect(diagnosticDialog).toHaveTextContent('"encoder_name": "go-silk"')
|
|
||||||
expect(diagnosticDialog).not.toHaveTextContent('aesKey')
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制诊断 JSON' }))
|
|
||||||
await waitFor(() => expect(copyText).toHaveBeenCalledWith(expect.stringContaining('request-1')))
|
|
||||||
expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows an empty state when no voice diagnostic exists', async () => {
|
|
||||||
renderDialog()
|
|
||||||
await startComposer()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '语音发送诊断' }))
|
|
||||||
expect(await screen.findByText('暂无诊断信息')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: '复制诊断 JSON' })).toBeDisabled()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -160,11 +160,11 @@ describe('PersonalWechatSendPage on Windows', () => {
|
|||||||
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
|
||||||
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('heading', { name: '个人微信发送' })).toBeVisible()
|
expect(screen.getByRole('heading', { name: '文字转语音' })).toBeVisible()
|
||||||
expect(screen.getByRole('region', { name: '发送消息' })).toBeVisible()
|
expect(screen.getByRole('region', { name: '文字转语音' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the Windows send dialog in text mode by default', async () => {
|
it('opens the Windows send dialog in TTS mode by default', async () => {
|
||||||
getPersonalWechatSenderStatus.mockResolvedValue(windowsStatus)
|
getPersonalWechatSenderStatus.mockResolvedValue(windowsStatus)
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -180,11 +180,8 @@ describe('PersonalWechatSendPage on Windows', () => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(await screen.findByRole('radio', { name: '文字' })).toHaveAttribute(
|
expect(await screen.findByRole('textbox', { name: '语音文字' })).toBeVisible()
|
||||||
'data-state',
|
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
|
||||||
'checked'
|
|
||||||
)
|
|
||||||
expect(screen.getByRole('textbox', { name: '消息内容' })).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the Windows voice page focused on speech settings', async () => {
|
it('keeps the Windows voice page focused on speech settings', async () => {
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
|
|||||||
try {
|
try {
|
||||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||||
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
|
const trigger = fixture.page.getByRole('button', { name: '文字转语音' })
|
||||||
await trigger.click()
|
await trigger.click()
|
||||||
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
|
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
|
|||||||
try {
|
try {
|
||||||
await fixture.setWindowContentSize(visualViewport)
|
await fixture.setWindowContentSize(visualViewport)
|
||||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||||
await fixture.page.getByRole('button', { name: '发送消息' }).click()
|
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
|
||||||
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
|
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
||||||
|
|||||||
@@ -133,18 +133,23 @@ describe('preload IPC contract', () => {
|
|||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:checkStatus', '4567')
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:checkStatus', '4567')
|
||||||
await api.rebindPersonalWechatSender()
|
await api.rebindPersonalWechatSender()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:rebind')
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:rebind')
|
||||||
const sendRequest = {
|
const ttsRequest = {
|
||||||
to: 'fixture@chatroom',
|
to: 'fixture@chatroom',
|
||||||
type: 'text' as const,
|
isGroup: true,
|
||||||
text: '测试消息',
|
filePath: '/tmp/generated.mp3'
|
||||||
isGroup: true
|
|
||||||
}
|
}
|
||||||
await api.sendPersonalWechatMessage(sendRequest)
|
await api.sendGeneratedTtsVoice(ttsRequest)
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', sendRequest)
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:sendGeneratedTtsVoice', ttsRequest)
|
||||||
|
const reportImageRequest = {
|
||||||
|
type: 'image' as const,
|
||||||
|
to: 'fixture@chatroom',
|
||||||
|
isGroup: true,
|
||||||
|
filePath: '/tmp/report.png'
|
||||||
|
}
|
||||||
|
await api.sendPersonalWechatMessage(reportImageRequest)
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', reportImageRequest)
|
||||||
await api.getPersonalWechatVoiceDiagnostic()
|
await api.getPersonalWechatVoiceDiagnostic()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
|
||||||
await api.selectPersonalWechatImage()
|
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
|
|
||||||
|
|
||||||
await api.getScheduledReportNotificationSettings()
|
await api.getScheduledReportNotificationSettings()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:getNotificationSettings')
|
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:getNotificationSettings')
|
||||||
|
|||||||
Reference in New Issue
Block a user