fix(chat): apply unlocked voice audio url

This commit is contained in:
2026-07-10 11:15:59 +08:00
parent 9a54e61517
commit 8312d08653
12 changed files with 76 additions and 25 deletions
+3 -2
View File
@@ -47,6 +47,7 @@ export function MessageContent({
lockedPrivate === true || lockedPrivate === true ||
(locked === true && lockReason === "private_message"); (locked === true && lockReason === "private_message");
const isLockedVoiceMessage = locked === true && lockReason === "voice_message"; const isLockedVoiceMessage = locked === true && lockReason === "voice_message";
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
const handleUnlockPrivateMessage = const handleUnlockPrivateMessage =
messageId && onUnlockPrivateMessage messageId && onUnlockPrivateMessage
? () => onUnlockPrivateMessage(messageId) ? () => onUnlockPrivateMessage(messageId)
@@ -79,7 +80,7 @@ export function MessageContent({
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
/> />
)} )}
{hasAudio && audioUrl ? ( {shouldRenderVoiceMessage ? (
<VoiceBubble <VoiceBubble
messageId={messageId} messageId={messageId}
audioUrl={audioUrl} audioUrl={audioUrl}
@@ -90,7 +91,7 @@ export function MessageContent({
onUnlock={handleUnlockVoiceMessage} onUnlock={handleUnlockVoiceMessage}
/> />
) : null} ) : null}
{hasText && !hasAudio ? ( {hasText && !shouldRenderVoiceMessage ? (
<TextBubble content={content} isFromAI={isFromAI} /> <TextBubble content={content} isFromAI={isFromAI} />
) : null} ) : null}
</> </>
+5 -4
View File
@@ -8,7 +8,7 @@ import styles from "./voice-bubble.module.css";
export interface VoiceBubbleProps { export interface VoiceBubbleProps {
messageId?: string; messageId?: string;
audioUrl: string; audioUrl?: string | null;
isFromAI: boolean; isFromAI: boolean;
locked?: boolean; locked?: boolean;
hint?: string | null; hint?: string | null;
@@ -28,6 +28,7 @@ export function VoiceBubble({
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null); const [errorMediaUrl, setErrorMediaUrl] = useState<string | null>(null);
const hasAudio = audioUrl != null && audioUrl.length > 0;
const { mediaUrl, isUsingCachedMedia, reportMediaError } = const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({ useCachedChatMediaUrl({
messageId, messageId,
@@ -66,7 +67,7 @@ export function VoiceBubble({
const hasError = errorMediaUrl === mediaUrl; const hasError = errorMediaUrl === mediaUrl;
const handleToggle = () => { const handleToggle = () => {
if (locked) return; if (locked || !hasAudio) return;
const audio = audioRef.current; const audio = audioRef.current;
if (!audio || hasError) return; if (!audio || hasError) return;
@@ -92,7 +93,7 @@ export function VoiceBubble({
type="button" type="button"
className={styles.playButton} className={styles.playButton}
onClick={handleToggle} onClick={handleToggle}
disabled={locked || hasError} disabled={locked || hasError || !hasAudio}
aria-label={isPlaying ? "Pause voice message" : "Play voice message"} aria-label={isPlaying ? "Pause voice message" : "Play voice message"}
> >
{locked ? ( {locked ? (
@@ -132,7 +133,7 @@ export function VoiceBubble({
</> </>
) : null} ) : null}
</div> </div>
{!locked ? ( {!locked && hasAudio ? (
<audio ref={audioRef} src={mediaUrl} preload="metadata" /> <audio ref={audioRef} src={mediaUrl} preload="metadata" />
) : null} ) : null}
</div> </div>
@@ -7,6 +7,7 @@ describe("UnlockPrivateResponse", () => {
const response = UnlockPrivateResponse.from({ const response = UnlockPrivateResponse.from({
unlocked: true, unlocked: true,
content: "完整消息内容", content: "完整消息内容",
audioUrl: "https://example.com/unlocked-voice.mp3",
reason: "ok", reason: "ok",
creditBalance: 9180, creditBalance: 9180,
creditsCharged: 10, creditsCharged: 10,
@@ -24,6 +25,7 @@ describe("UnlockPrivateResponse", () => {
expect(response.unlocked).toBe(true); expect(response.unlocked).toBe(true);
expect(response.content).toBe("完整消息内容"); expect(response.content).toBe("完整消息内容");
expect(response.audioUrl).toBe("https://example.com/unlocked-voice.mp3");
expect(response.reason).toBe("ok"); expect(response.reason).toBe("ok");
expect(response.creditBalance).toBe(9180); expect(response.creditBalance).toBe(9180);
expect(response.creditsCharged).toBe(10); expect(response.creditsCharged).toBe(10);
@@ -66,6 +68,7 @@ describe("UnlockPrivateResponse", () => {
const response = UnlockPrivateResponse.from({ const response = UnlockPrivateResponse.from({
unlocked: false, unlocked: false,
content: null, content: null,
audioUrl: null,
reason: "insufficient_balance", reason: "insufficient_balance",
lockDetail: { lockDetail: {
locked: true, locked: true,
@@ -78,6 +81,7 @@ describe("UnlockPrivateResponse", () => {
}); });
expect(response.content).toBe(""); expect(response.content).toBe("");
expect(response.audioUrl).toBe("");
}); });
it("defaults nullable credit fields to zero", () => { it("defaults nullable credit fields to zero", () => {
@@ -12,6 +12,7 @@ import type { ChatLockDetailData } from "@/data/schemas/chat";
export class UnlockPrivateResponse { export class UnlockPrivateResponse {
declare readonly unlocked: boolean; declare readonly unlocked: boolean;
declare readonly content: string; declare readonly content: string;
declare readonly audioUrl: string;
declare readonly reason: UnlockPrivateReason; declare readonly reason: UnlockPrivateReason;
declare readonly creditBalance: number; declare readonly creditBalance: number;
declare readonly creditsCharged: number; declare readonly creditsCharged: number;
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { ChatLockDetailData } from "@/data/schemas/chat";
import { ChatMessage } from "@/data/dto/chat"; import { ChatMessage } from "@/data/dto/chat";
import type { UnlockedPrivateMessageLocalPatch } from "@/data/repositories/interfaces";
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat"; import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
import { Result } from "@/utils"; import { Result } from "@/utils";
@@ -10,7 +10,7 @@ export class ChatLocalMessageStore {
async markMessageUnlocked( async markMessageUnlocked(
messageId: string, messageId: string,
lockDetail?: ChatLockDetailData, patch: UnlockedPrivateMessageLocalPatch = {},
): Promise<Result<void>> { ): Promise<Result<void>> {
return Result.wrap(async () => { return Result.wrap(async () => {
const localResult = await this.getMessages(); const localResult = await this.getMessages();
@@ -24,7 +24,9 @@ export class ChatLocalMessageStore {
changed = true; changed = true;
return ChatMessage.from({ return ChatMessage.from({
...message.toJson(), ...message.toJson(),
lockDetail: lockDetail ?? { content: normalizeUnlockedContent(patch.content, message.content),
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
lockDetail: patch.lockDetail ?? {
...message.lockDetail, ...message.lockDetail,
locked: false, locked: false,
showContent: true, showContent: true,
@@ -105,6 +107,22 @@ export class ChatLocalMessageStore {
} }
} }
function normalizeUnlockedContent(
nextContent: string | undefined,
currentContent: string,
): string {
if (nextContent == null || nextContent.length === 0) return currentContent;
return nextContent;
}
function normalizeUnlockedAudioUrl(
nextAudioUrl: string | null | undefined,
currentAudioUrl: string | null,
): string | null {
if (nextAudioUrl == null || nextAudioUrl.length === 0) return currentAudioUrl;
return nextAudioUrl;
}
function shouldMarkMessageUnlocked( function shouldMarkMessageUnlocked(
message: ChatMessage, message: ChatMessage,
messageId: string, messageId: string,
+3 -3
View File
@@ -7,7 +7,6 @@ import type {
UnlockHistoryResponse, UnlockHistoryResponse,
UnlockPrivateResponse, UnlockPrivateResponse,
} from "@/data/dto/chat"; } from "@/data/dto/chat";
import type { ChatLockDetailData } from "@/data/schemas/chat";
import { chatApi } from "@/data/services/api"; import { chatApi } from "@/data/services/api";
import { import {
LocalChatMediaStorage, LocalChatMediaStorage,
@@ -18,6 +17,7 @@ import type {
CacheRemoteChatMediaInput, CacheRemoteChatMediaInput,
ChatMediaLookupInput, ChatMediaLookupInput,
IChatRepository, IChatRepository,
UnlockedPrivateMessageLocalPatch,
} from "@/data/repositories/interfaces"; } from "@/data/repositories/interfaces";
import type { Result } from "@/utils"; import type { Result } from "@/utils";
@@ -66,11 +66,11 @@ export class ChatRepository implements IChatRepository {
/** 把本地缓存中的单条锁定消息标记为已解锁。 */ /** 把本地缓存中的单条锁定消息标记为已解锁。 */
async markPrivateMessageUnlockedInLocal( async markPrivateMessageUnlockedInLocal(
messageId: string, messageId: string,
lockDetail?: ChatLockDetailData, patch?: UnlockedPrivateMessageLocalPatch,
): Promise<Result<void>> { ): Promise<Result<void>> {
return this.localMessages.markMessageUnlocked( return this.localMessages.markMessageUnlocked(
messageId, messageId,
lockDetail, patch,
); );
} }
@@ -32,6 +32,12 @@ export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
remoteUrl: string; remoteUrl: string;
} }
export interface UnlockedPrivateMessageLocalPatch {
lockDetail?: ChatLockDetailData;
audioUrl?: string | null;
content?: string;
}
export interface IChatRepository { export interface IChatRepository {
/** 发送一条消息。 */ /** 发送一条消息。 */
sendMessage( sendMessage(
@@ -51,7 +57,7 @@ export interface IChatRepository {
/** 把本地缓存中的单条锁定消息标记为已解锁。 */ /** 把本地缓存中的单条锁定消息标记为已解锁。 */
markPrivateMessageUnlockedInLocal( markPrivateMessageUnlockedInLocal(
messageId: string, messageId: string,
lockDetail?: ChatLockDetailData, patch?: UnlockedPrivateMessageLocalPatch,
): Promise<Result<void>>; ): Promise<Result<void>>;
/** 把一条消息写入本地存储。 */ /** 把一条消息写入本地存储。 */
@@ -11,6 +11,7 @@ export const UnlockPrivateReasonSchema = stringOrEmpty;
export const UnlockPrivateResponseSchema = z.object({ export const UnlockPrivateResponseSchema = z.object({
unlocked: booleanOrFalse, unlocked: booleanOrFalse,
content: stringOrEmpty, content: stringOrEmpty,
audioUrl: stringOrEmpty,
reason: UnlockPrivateReasonSchema, reason: UnlockPrivateReasonSchema,
creditBalance: numberOrZero, creditBalance: numberOrZero,
creditsCharged: numberOrZero, creditsCharged: numberOrZero,
@@ -66,8 +66,7 @@ describe("sendResponseToUiMessage", () => {
it("maps locked voice messages without treating them as private text", () => { it("maps locked voice messages without treating them as private text", () => {
const message = sendResponseToUiMessage( const message = sendResponseToUiMessage(
makeResponse({ makeResponse({
audioUrl: audioUrl: "",
"https://proapi.banlv-ai.com/audio/5198b93c-2915-406a.mp3",
lockDetail: { lockDetail: {
locked: true, locked: true,
showContent: false, showContent: false,
@@ -80,9 +79,7 @@ describe("sendResponseToUiMessage", () => {
); );
expect(message.content).toBe("AI reply"); expect(message.content).toBe("AI reply");
expect(message.audioUrl).toBe( expect(message.audioUrl).toBeUndefined();
"https://proapi.banlv-ai.com/audio/5198b93c-2915-406a.mp3",
);
expect(message.locked).toBe(true); expect(message.locked).toBe(true);
expect(message.lockReason).toBe("voice_message"); expect(message.lockReason).toBe("voice_message");
expect(message.lockedPrivate).toBeUndefined(); expect(message.lockedPrivate).toBeUndefined();
@@ -243,7 +240,7 @@ describe("localMessagesToUi", () => {
type: "voice", type: "voice",
content: "hidden voice transcript", content: "hidden voice transcript",
createdAt: "2026-06-25T12:00:00.000Z", createdAt: "2026-06-25T12:00:00.000Z",
audioUrl: "https://example.com/voice.mp3", audioUrl: null,
image: { type: null, url: null }, image: { type: null, url: null },
lockDetail: { lockDetail: {
locked: true, locked: true,
@@ -257,7 +254,7 @@ describe("localMessagesToUi", () => {
]); ]);
expect(message.content).toBe("hidden voice transcript"); expect(message.content).toBe("hidden voice transcript");
expect(message.audioUrl).toBe("https://example.com/voice.mp3"); expect(message.audioUrl).toBeUndefined();
expect(message.locked).toBe(true); expect(message.locked).toBe(true);
expect(message.lockReason).toBe("voice_message"); expect(message.lockReason).toBe("voice_message");
expect(message.lockedPrivate).toBeUndefined(); expect(message.lockedPrivate).toBeUndefined();
@@ -59,6 +59,7 @@ function makeUnlockPrivateResponse(
return UnlockPrivateResponse.from({ return UnlockPrivateResponse.from({
unlocked: true, unlocked: true,
content: "unlocked content", content: "unlocked content",
audioUrl: "",
reason: "ok", reason: "ok",
creditBalance: 90, creditBalance: 90,
creditsCharged: 10, creditsCharged: 10,
@@ -740,7 +741,7 @@ describe("chatMachine transitions", () => {
actor.stop(); actor.stop();
}); });
it("keeps voice message content unchanged after unlock succeeds", async () => { it("applies the real voice audio url after unlock succeeds", async () => {
const actor = createActor( const actor = createActor(
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
@@ -749,7 +750,6 @@ describe("chatMachine transitions", () => {
content: "Original voice transcript.", content: "Original voice transcript.",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
audioUrl: "https://example.com/voice.mp3",
locked: true, locked: true,
lockReason: "voice_message", lockReason: "voice_message",
privateMessageHint: "A voice message is waiting.", privateMessageHint: "A voice message is waiting.",
@@ -759,6 +759,7 @@ describe("chatMachine transitions", () => {
messageId: "msg-voice-locked", messageId: "msg-voice-locked",
response: makeUnlockPrivateResponse({ response: makeUnlockPrivateResponse({
content: "This response content must be ignored.", content: "This response content must be ignored.",
audioUrl: "https://example.com/unlocked-voice.mp3",
}), }),
}, },
}), }),
@@ -783,7 +784,7 @@ describe("chatMachine transitions", () => {
{ {
id: "msg-voice-locked", id: "msg-voice-locked",
content: "Original voice transcript.", content: "Original voice transcript.",
audioUrl: "https://example.com/voice.mp3", audioUrl: "https://example.com/unlocked-voice.mp3",
locked: false, locked: false,
lockReason: null, lockReason: null,
privateMessageHint: null, privateMessageHint: null,
@@ -802,7 +803,6 @@ describe("chatMachine transitions", () => {
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
audioUrl: "https://example.com/voice.mp3",
locked: true, locked: true,
lockReason: "voice_message", lockReason: "voice_message",
privateMessageHint: "A voice message is waiting.", privateMessageHint: "A voice message is waiting.",
@@ -855,6 +855,14 @@ describe("chatMachine transitions", () => {
requiredCredits: 10, requiredCredits: 10,
shortfallCredits: 7, shortfallCredits: 7,
}); });
expect(actor.getSnapshot().context.messages).toMatchObject([
{
id: "msg-voice-locked",
locked: true,
lockReason: "voice_message",
},
]);
expect(actor.getSnapshot().context.messages[0]?.audioUrl).toBeUndefined();
expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false); expect(actor.getSnapshot().context.isUnlockingMessage).toBe(false);
actor.stop(); actor.stop();
+5 -1
View File
@@ -64,7 +64,11 @@ export const unlockMessageActor = fromPromise<
if (unlockResult.data.unlocked) { if (unlockResult.data.unlocked) {
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal( const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
input.messageId, input.messageId,
unlockResult.data.lockDetail, {
content: unlockResult.data.content,
audioUrl: unlockResult.data.audioUrl,
lockDetail: unlockResult.data.lockDetail,
},
); );
if (Result.isErr(markResult)) { if (Result.isErr(markResult)) {
log.warn("[chat-machine] mark unlocked local message failed", { log.warn("[chat-machine] mark unlocked local message failed", {
+10
View File
@@ -20,6 +20,7 @@ export function applySingleUnlockOutput(
return { return {
...message, ...message,
audioUrl: getUnlockedAudioUrl(message, output.response),
locked: output.response.lockDetail.locked, locked: output.response.lockDetail.locked,
lockReason: output.response.lockDetail.reason, lockReason: output.response.lockDetail.reason,
imagePaywalled: message.imageUrl imagePaywalled: message.imageUrl
@@ -32,6 +33,15 @@ export function applySingleUnlockOutput(
}); });
} }
function getUnlockedAudioUrl(
message: UiMessage,
response: UnlockPrivateResponse,
): string | undefined {
if (message.lockReason !== "voice_message") return message.audioUrl;
if (response.audioUrl.length === 0) return message.audioUrl;
return response.audioUrl;
}
function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean { function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean {
if (message.id !== messageId) return false; if (message.id !== messageId) return false;
if (!message.isFromAI) return false; if (!message.isFromAI) return false;