docs(chat): consolidate multi-role protocol

This commit is contained in:
2026-07-20 19:08:24 +08:00
parent edf50e9cc4
commit 9f176272c1
12 changed files with 276 additions and 1117 deletions
+1 -2
View File
@@ -98,7 +98,7 @@ export function ChatScreen() {
[imageMessageId, visibleMessages],
);
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
// Guest status belongs to auth state and is not duplicated in the Chat actor.
const isGuest = deriveIsGuest(authState.loginStatus);
// 发送能力由后端统一返回,当前只处理积分不足引导。
@@ -251,7 +251,6 @@ export function ChatScreen() {
</div>
<div className="relative z-1 flex min-h-0 flex-[1_1_auto] flex-col">
{/* isGuest 派生自 auth.loginStatus */}
<ChatHeader
isGuest={isGuest}
showBrowserHint={shouldShowBrowserHint}
@@ -8,10 +8,9 @@
* - 多行输入(min 1 行 / max 5 行 ≈ max-height 120px
* - Enter 直接发送(无 Shift/Ctrl/Meta
* - Shift+Enter / Ctrl+Enter / Meta+Enter 换行
* - 外层白底圆角容器(与 Dart `Container(borderRadius: AppRadius.xxxl ≈ 32)` 一致)
* - 外层白底圆角容器
*
* 通过 `forwardRef` 暴露 textarea 节点,父组件可在 send 后调用 `.focus()`
* (对齐 Dart `_focusNode.requestFocus()`)。
* 通过 `onFocusChange` 回调通知父组件焦点状态,用于外层容器的聚焦样式切换。
*/
import {
@@ -1,16 +1,5 @@
"use client";
/**
* LottieMessageBubble 动画消息气泡
*
*
*
* 原始实现:使用 lottie SDK 渲染"打字指示器"动画
* 本轮实现:使用 CSS keyframes 模拟(无 lottie 依赖)
* - 3 个跳动的圆点(左右 + 中间)
* - 1.4s 周期,stagger 动画延迟
*
* 行为一致(视觉类似 lottie 的"三点跳跃"动画)
*/
/** CSS typing indicator loaded lazily with the rest of the reply UI. */
import { MessageAvatar } from "./message-avatar";
import styles from "./lottie-message-bubble.module.css";
@@ -5,7 +5,7 @@
*
*
* 用途:引导用户将 Web App 安装到桌面
* 触发:由 PwaInstallOverlay 在延迟 3.5s 后自动显示
* 触发:由 PwaInstallOverlay 在满足安装条件后延迟显示
*/
import Image from "next/image";
+10 -36
View File
@@ -3,15 +3,10 @@
* PwaInstallOverlay PWA 安装提示触发器
*
* 行为:
* - 等聊天消息数量达到触发阈值后才开始检查
* - 检查 PWA 支持(pwaUtil.isSupported
* - 检查 PWA 是否已安装(pwaUtil.isInstalled)—— 已安装则不弹
* - 检查是否已经显示过(使用 AppStorage 持久化)
* - 延迟后弹出 PwaInstallDialog
* - 生产环境有效;开发环境 1s 后立即弹出(测试用)
*
* 注意:PWA install prompt API`beforeinstallprompt`)在浏览器差异较大,
* 本轮仅实现"显示时机"逻辑,具体 install prompt 调用由 PwaInstallDialog 处理。
* - 调用方达到消息阈值后启用检查
* - 仅在浏览器可安装、尚未安装且未展示过时继续
* - 延迟 1.5 秒显示 PwaInstallDialog
* - 用户确认后调用浏览器安装提示
*/
import { useEffect } from "react";
@@ -23,8 +18,7 @@ import { pwaUtil } from "@/utils/pwa";
import { PwaInstallDialog } from "./pwa-install-dialog";
const DEVELOPMENT_DIALOG_DELAY_MS = 1000;
const PRODUCTION_DIALOG_DELAY_MS = 1500;
const DIALOG_DELAY_MS = 1500;
export interface PwaInstallOverlayProps {
enabled: boolean;
@@ -39,24 +33,18 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) {
let unsubscribe: (() => void) | undefined;
const init = async () => {
// const isDev = AppEnvUtil.isDevelopment();
const isDev = false;
// 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。
pwaUtil.prepareInstallPrompt();
const tryScheduleDialog = async () => {
const shouldShowDialog = await shouldShowPwaInstallDialog(isDev);
const shouldShowDialog = await shouldShowPwaInstallDialog();
if (!mounted || !shouldShowDialog || timer !== undefined) return;
// 达到聊天数量阈值后稍作停顿,避免弹窗与消息刷新挤在同一瞬间。
const delay = isDev
? DEVELOPMENT_DIALOG_DELAY_MS
: PRODUCTION_DIALOG_DELAY_MS;
timer = window.setTimeout(() => {
if (!mounted) return;
showPwaDialog();
}, delay);
}, DIALOG_DELAY_MS);
};
await tryScheduleDialog();
@@ -76,26 +64,18 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) {
return null;
}
async function shouldShowPwaInstallDialog(isDevelopment: boolean) {
// PWA 支持 / 安装 双重门禁(两种环境都检查)。
async function shouldShowPwaInstallDialog() {
if (!pwaUtil.canShowInstallEntry()) return false;
// 开发环境:跳过"只弹一次"门禁,每次进入都弹,方便 UI 测试。
if (isDevelopment) return true;
// 生产环境:永久只弹一次(防骚扰)。
return canShowPwaInstallPromptOnce();
}
function showPwaDialog() {
if (typeof document === "undefined") return;
// 记录已显示,后续不再弹出。
// Persist before rendering so remounts cannot schedule a second dialog.
void recordPwaInstallPromptShown();
// 创建挂载点
const root = document.createElement("div");
document.body.appendChild(root);
// 用 React DOM 渲染(通过 import
import("react-dom/client").then(({ createRoot }) => {
const reactRoot = createRoot(root);
reactRoot.render(<PwaInstallDialogMount rootEl={root} reactRoot={reactRoot} />);
@@ -116,13 +96,7 @@ function PwaInstallDialogMount({
rootEl.remove();
}}
onInstall={async () => {
// 走 pwaUtil.install()
// - 优先使用已缓存的 deferred prompt
// - 如未缓存,再等待 deferred 事件 ready3s 超时)
// - 调浏览器原生 prompt
// - 返 "accepted" / "dismissed" / "unavailable"
// 当前不消费返回值 —— 用户已经看到 dialog 点了 OK,是否真装上是浏览器的事。
// 后续可在此接 metrics 上报(pwa_accepted / pwa_dismissed / pwa_unavailable)。
// The utility owns deferred-prompt lookup and browser prompting.
await pwaUtil.install();
}}
/>
+2 -2
View File
@@ -103,7 +103,7 @@ export class ChatRepository implements IChatRepository {
/**
* 批量覆盖写入:先清空本地存储,再写入新列表。
* 保留 Dart 端的「先清后写」语义(用于同步场景的整批刷新
* 用于网络历史同步后的整批刷新。
*/
async saveMessagesToLocal(
messages: readonly ChatMessage[],
@@ -126,7 +126,7 @@ export class ChatRepository implements IChatRepository {
/**
* 获取本地消息数量。
* 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async
* Dexie 查询是异步操作,因此始终返回 Promise
*/
async getLocalMessageCount(cacheIdentity?: string): Promise<Result<number>> {
return this.localMessages.getMessageCount(cacheIdentity);
+2 -7
View File
@@ -8,12 +8,7 @@ import { chatMachine } from "./chat-machine";
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
import { appendPromotionMessage } from "./helper/promotion";
/**
* 对外暴露的 State 形状
*
* isGuest 字段已删 —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
*/
/** Chat UI reads this projection instead of the machine snapshot directly. */
interface ChatState {
characterId: string;
characterErrorCode: MachineContext["characterErrorCode"];
@@ -25,7 +20,7 @@ interface ChatState {
canSendMessage: boolean;
upgradePromptVisible: boolean;
upgradeReason: MachineContext["upgradeReason"];
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
/** True as soon as a local snapshot or the first network result is renderable. */
historyLoaded: boolean;
hasMoreHistory: boolean;
isLoadingMoreHistory: boolean;
+5 -17
View File
@@ -1,26 +1,14 @@
/**
* Chat 状态机:事件联合
*
* 事件名与原 Dart ChatEvent 对齐。
*
* 历史:原本有 `ChatAuthStatusChanged`chat 机器刷新 isGuest)—— 已删。
* 鉴权状态变化(loginStatus)由 <ChatAuthSync /> 通过 `useAuthState()` 订阅,
* chat 机器不感知鉴权。
*
* 鉴权生命周期事件(本轮新增):
* - `ChatGuestLogin`:游客进入 /chat —— 拉服务器端首屏
* - `ChatUserLogin`:其他登录用户进入 /chat —— 拉服务器端首屏
* - `ChatLogout`:正式登录用户登出 —— 清消息
*
* 设计:所有历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
* chat-screen 不直接读 AuthStorage(除 token 取值)。
* Public events for one character-scoped Chat actor.
* Authentication synchronization dispatches the lifecycle events; history,
* quota, send, and unlock side effects remain owned by the machine.
*/
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation";
export type ChatEvent =
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
// Authentication lifecycle dispatched by ChatAuthSync.
| { type: "ChatGuestLogin" }
| { type: "ChatUserLogin"; token: string }
| { type: "ChatLogout" }
@@ -40,7 +28,7 @@ export type ChatEvent =
}
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
| { type: "ChatHistoryRefreshRequested" }
// 业务事件
// Chat domain events.
| { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string }
| {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { UiMessage } from "@/stores/chat/ui-message";
import type { ChatState } from "../chat-state";
// The initial history request remains bounded even though pagination is disabled.
// Initial and subsequent history pages use the same bounded page size.
export const CHAT_HISTORY_LIMIT = 50;
export function applyHistoryLoadedOutput(