docs(chat): consolidate multi-role protocol
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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 事件 ready(3s 超时)
|
||||
// - 调浏览器原生 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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user