feat(memoir+conversation): 章节/故事最小可读字数;会话 hasUserMessage 与 UI 优化

- 后端:300 字门槛统一物化、hydrate、列表/PDF/详情;过短章节对读者隐藏
- 对话:首包前打字动画、大字模式排版、朗读/TTS 交互与布局稳定
- 首页:复用无用户消息会话;空列表「继续对话」与文案 i18n
- 章节阅读:标题进正文、封面与去重标题;阅读 Markdown 字号上调
This commit is contained in:
Kevin
2026-03-26 16:28:33 +08:00
parent d990399112
commit 1374f6e8f5
15 changed files with 708 additions and 198 deletions

View File

@@ -100,7 +100,7 @@ function updateListPreview(
queryClient: QueryClient,
conversationId: string,
text: string,
_senderType: 'user' | 'assistant',
senderType: 'user' | 'assistant',
): void {
queryClient.setQueryData<ConversationListItem[]>(
conversationKeys.lists(),
@@ -112,6 +112,7 @@ function updateListPreview(
...item,
latestMessagePreview: text.slice(0, 50),
latestMessageTime: nowMs(),
...(senderType === 'user' ? { hasUserMessage: true } : {}),
}
: item,
);

View File

@@ -65,6 +65,7 @@ export function useCreateConversation() {
latestMessageTime: Date.now(),
unreadCount: 0,
isDefaultAssistant: true,
hasUserMessage: false,
};
return [item, ...(old ?? [])];
},
@@ -134,6 +135,8 @@ function generateUUID(): string {
interface RealtimeSessionState {
connectionState: WsConnectionState;
streamingMessage: StreamingAgentMessage | null;
/** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */
awaitingAssistantReply: boolean;
error: string | null;
sendText: (text: string) => void;
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
@@ -154,12 +157,17 @@ export function useRealtimeSession({
useState<WsConnectionState>('disconnected');
const [streamingMessage, setStreamingMessage] =
useState<StreamingAgentMessage | null>(null);
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleStreamingText: StreamingTextCallback = useCallback(
(text, isComplete) => {
if (text.trim().length > 0) {
setAwaitingAssistantReply(false);
}
if (isComplete) {
setStreamingMessage(null);
setAwaitingAssistantReply(false);
return;
}
setStreamingMessage({ text, isComplete });
@@ -168,6 +176,7 @@ export function useRealtimeSession({
);
const handleError: ErrorCallback = useCallback((message) => {
setAwaitingAssistantReply(false);
setError(message);
}, []);
@@ -191,6 +200,7 @@ export function useRealtimeSession({
sessionRef.current = null;
setConnectionState('disconnected');
setStreamingMessage(null);
setAwaitingAssistantReply(false);
};
}, [
conversationId,
@@ -211,6 +221,7 @@ export function useRealtimeSession({
return;
}
setAwaitingAssistantReply(true);
onTtsPlaybackResume?.();
const localId = `pending_${Date.now()}`;
@@ -229,6 +240,18 @@ export function useRealtimeSession({
return [...(old ?? []), msg];
},
);
queryClient.setQueryData<ConversationListItem[]>(
conversationKeys.lists(),
(old) => {
if (!old) return old;
return old.map((item) =>
item.id === conversationId
? { ...item, hasUserMessage: true }
: item,
);
},
);
},
[conversationId, queryClient, onTtsPlaybackResume],
);
@@ -258,6 +281,7 @@ export function useRealtimeSession({
return false;
}
setAwaitingAssistantReply(true);
const localId = `pending_voice_${Date.now()}`;
await voiceSegmentStore.recordSentSegment({
voiceSessionId,
@@ -282,6 +306,18 @@ export function useRealtimeSession({
return [...(old ?? []), msg];
},
);
queryClient.setQueryData<ConversationListItem[]>(
conversationKeys.lists(),
(old) => {
if (!old) return old;
return old.map((item) =>
item.id === conversationId
? { ...item, hasUserMessage: true }
: item,
);
},
);
onTtsPlaybackResume?.();
return true;
} catch {
@@ -303,6 +339,7 @@ export function useRealtimeSession({
return {
connectionState,
streamingMessage,
awaitingAssistantReply,
error,
sendText,
sendVoiceMessage,

View File

@@ -20,6 +20,8 @@ export interface ConversationListItem {
latestMessageTime: number;
unreadCount: number;
isDefaultAssistant: boolean;
/** 是否已有用户发出的文本或语音(仅助手/空会话为 false用于「打个招呼」复用同一会话 */
hasUserMessage: boolean;
}
export interface ConversationDetail {

View File

@@ -189,8 +189,9 @@ const FONT_FAMILIES = {
}) ?? 'sans-serif',
};
const FONT_SIZES = { small: 16, default: 20, large: 24 };
const LINE_HEIGHTS = { small: 30, default: 38, large: 44 };
/** 阅读页三档字号(再次上调,便于长时间阅读) */
const FONT_SIZES = { small: 22, default: 27, large: 33 };
const LINE_HEIGHTS = { small: 40, default: 50, large: 60 };
export interface MarkdownRendererProps {
markdown: string;