Merge branch 'dev' into test
This commit is contained in:
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -1,19 +1,23 @@
|
||||
/* 双层 background 实现渐变描边:
|
||||
* 内层白色填充到 padding-box,
|
||||
* 外层粉色→品红渐变填充到 border-box。 */
|
||||
.card {
|
||||
background:
|
||||
linear-gradient(#ffffff, #ffffff) padding-box,
|
||||
background-image:
|
||||
url("/images/sidebar/pic_bg_vip.png"),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--color-auth-primary-gradient-start, #f96ade),
|
||||
var(--color-auth-primary-gradient-end, #f657a0)
|
||||
) border-box;
|
||||
border: var(--border-card, 1.5px) solid transparent;
|
||||
border-radius: var(--radius-xl, 16px);
|
||||
box-shadow: var(--shadow-card, 0 1px 3px rgba(0, 0, 0, 0.04));
|
||||
-60deg,
|
||||
rgba(251, 106, 67, 0.47) 0%,
|
||||
rgba(252, 125, 185, 0.47) 32%,
|
||||
rgba(250, 167, 186, 0.47) 58%,
|
||||
rgba(255, 255, 255, 0.47) 100%
|
||||
),
|
||||
linear-gradient(#ffffff, #ffffff);
|
||||
background-repeat: no-repeat, no-repeat, no-repeat;
|
||||
background-position: right center, center, center;
|
||||
background-size: 50% 100%, cover, cover;
|
||||
background-blend-mode: normal, normal, normal;
|
||||
border: solid 1px #fac4dc;
|
||||
border-radius: 21px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 18px 0 18px 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -22,14 +26,14 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md, 12px);
|
||||
padding: var(--spacing-lg, 16px) var(--spacing-lg, 16px) var(--spacing-sm, 8px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg, 16px);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent, #f84d96);
|
||||
color: #f657a0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -50,25 +54,29 @@
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
margin: 18px 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: var(--spacing-sm, 8px) var(--spacing-lg, 16px);
|
||||
font-size: var(--font-size-md, 14px);
|
||||
color: var(--color-auth-text-primary, #333333);
|
||||
line-height: 1.4;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: #1e1e1e;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.numeral {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
min-width: 20px;
|
||||
color: var(--color-auth-text-primary, #333333);
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
.numeral::after {
|
||||
content: " ";
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.text {
|
||||
@@ -76,7 +84,7 @@
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px) var(--spacing-lg, 16px);
|
||||
padding: var(--spacing-md, 12px) 0 0;
|
||||
}
|
||||
|
||||
.activateBtn {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/* 双层 background 实现 pink → orange 渐变描边 */
|
||||
.card {
|
||||
background:
|
||||
linear-gradient(#ffffff, #ffffff) padding-box,
|
||||
background-image:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--color-voice-gradient-start, #ff6fa3),
|
||||
var(--color-voice-gradient-end, #ff9a5a)
|
||||
) border-box;
|
||||
border: var(--border-card, 1.5px) solid transparent;
|
||||
border-radius: var(--radius-xl, 16px);
|
||||
box-shadow: var(--shadow-card, 0 1px 3px rgba(0, 0, 0, 0.04));
|
||||
180deg,
|
||||
rgba(250, 208, 198, 0.57) 0%,
|
||||
rgba(250, 176, 165, 0.57) 0%,
|
||||
rgba(250, 143, 132, 0.57) 0%,
|
||||
rgba(251, 176, 156, 0.57) 8%,
|
||||
rgba(251, 208, 180, 0.57) 16%,
|
||||
rgba(255, 255, 255, 0.57) 38%,
|
||||
rgba(255, 255, 255, 0.57) 100%
|
||||
),
|
||||
linear-gradient(#ffffff, #ffffff);
|
||||
background-blend-mode: normal, normal;
|
||||
border: solid 1px #fbbcb6;
|
||||
border-radius: 21px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
@@ -33,23 +37,18 @@
|
||||
|
||||
.micIcon {
|
||||
flex: 0 0 auto;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-voice-gradient-start, #ff6fa3),
|
||||
var(--color-voice-gradient-end, #ff9a5a)
|
||||
);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(255, 111, 163, 0.25);
|
||||
}
|
||||
|
||||
.micGlyph {
|
||||
color: inherit;
|
||||
.micImage {
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.usage {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import styles from "./voice-package-card.module.css";
|
||||
|
||||
export interface VoicePackageCardProps {
|
||||
@@ -31,22 +33,13 @@ export function VoicePackageCard({
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>Voice Message Package</h2>
|
||||
<div className={styles.micIcon} aria-hidden="true">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={styles.micGlyph}
|
||||
>
|
||||
<path
|
||||
d="M12 14a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v5a3 3 0 0 0 3 3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M19 11a1 1 0 1 0-2 0 5 5 0 0 1-10 0 1 1 0 1 0-2 0 7 7 0 0 0 6 6.92V21a1 1 0 1 0 2 0v-3.08A7 7 0 0 0 19 11Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Image
|
||||
src="/images/sidebar/ic_user_voicemessage.png"
|
||||
alt=""
|
||||
width={100}
|
||||
height={100}
|
||||
className={styles.micImage}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./subscription-banner";
|
||||
export * from "./subscription-benefits-card";
|
||||
export * from "./subscription-checkout-button";
|
||||
export * from "./subscription-cta-button";
|
||||
export * from "./subscription-payment-method";
|
||||
export * from "./subscription-plan-card";
|
||||
export * from "./subscription-user-row";
|
||||
export * from "./stripe-payment-dialog";
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅页主操作按钮
|
||||
*
|
||||
* 视觉规格(与设计稿对齐):
|
||||
* - 全宽,52px 高,--radius-xxxl 圆角
|
||||
* - 粉色渐变(#f96ADE → #f657A0)
|
||||
* - 白字 16px/600 + 粉色阴影
|
||||
* - :disabled 灰化
|
||||
*/
|
||||
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import styles from "./subscription-cta-button.module.css";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: 0 var(--spacing-xs);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-athelas), var(--font-system);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-auth-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.caption {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
min-height: 62px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(246, 87, 160, 0.18);
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 5px 7px 0 rgba(247, 89, 168, 0.08);
|
||||
color: #3c3b3b;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease,
|
||||
transform 0.05s ease;
|
||||
}
|
||||
|
||||
.option:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.option:not(:disabled):active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: #f657a0;
|
||||
border-width: 2px;
|
||||
box-shadow: 0 6px 12px 0 rgba(247, 89, 168, 0.14);
|
||||
}
|
||||
|
||||
.optionTitle {
|
||||
color: #1e1e1e;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import type { PayChannel } from "@/data/dto/payment";
|
||||
|
||||
import styles from "./subscription-payment-method.module.css";
|
||||
|
||||
const PAYMENT_METHODS: Array<{
|
||||
channel: PayChannel;
|
||||
title: string;
|
||||
}> = [
|
||||
{
|
||||
channel: "stripe",
|
||||
title: "Stripe",
|
||||
},
|
||||
{
|
||||
channel: "ezpay",
|
||||
title: "Ezpay",
|
||||
},
|
||||
];
|
||||
|
||||
export interface SubscriptionPaymentMethodProps {
|
||||
value: PayChannel;
|
||||
disabled?: boolean;
|
||||
onChange: (channel: PayChannel) => void;
|
||||
}
|
||||
|
||||
export function SubscriptionPaymentMethod({
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: SubscriptionPaymentMethodProps) {
|
||||
return (
|
||||
<section className={styles.root} aria-labelledby="payment-method-title">
|
||||
<div className={styles.header}>
|
||||
<h2 id="payment-method-title" className={styles.title}>
|
||||
Payment Method
|
||||
</h2>
|
||||
<span className={styles.caption}>Stripe by default</span>
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
{PAYMENT_METHODS.map((method) => {
|
||||
const selected = method.channel === value;
|
||||
const classes = [
|
||||
styles.option,
|
||||
selected ? styles.selected : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method.channel}
|
||||
type="button"
|
||||
className={classes}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(method.channel)}
|
||||
>
|
||||
<span className={styles.optionTitle}>{method.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,10 @@
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.paymentMethodSlot {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.ctaSlot {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SubscriptionBenefitsCard,
|
||||
SubscriptionCheckoutButton,
|
||||
SubscriptionCtaButton,
|
||||
SubscriptionPaymentMethod,
|
||||
SubscriptionPlanCard,
|
||||
SubscriptionUserRow,
|
||||
} from "./components";
|
||||
@@ -250,6 +251,21 @@ export function SubscriptionScreen({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!isVip ? (
|
||||
<section className={styles.paymentMethodSlot}>
|
||||
<SubscriptionPaymentMethod
|
||||
value={payment.payChannel}
|
||||
disabled={isPaymentBusy}
|
||||
onChange={(payChannel) =>
|
||||
paymentDispatch({
|
||||
type: "PaymentPayChannelChanged",
|
||||
payChannel,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className={styles.ctaSlot}>
|
||||
{isVip ? (
|
||||
<SubscriptionCtaButton type="button" disabled>
|
||||
|
||||
@@ -58,9 +58,8 @@ export function ChatAuthSync() {
|
||||
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
||||
if (!cancelled && tokenR.success && tokenR.data) {
|
||||
chatDispatch({
|
||||
type: "ChatNonGuestLogin",
|
||||
type: isVip ? "ChatVipLogin" : "ChatNonVipLogin",
|
||||
token: tokenR.data,
|
||||
isVip,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
*
|
||||
* 鉴权生命周期事件(本轮新增):
|
||||
* - `ChatGuestLogin`:游客进入 /chat —— 拉本地消息 + 初始化配额(不连 WS)
|
||||
* - `ChatNonGuestLogin`:非游客进入 /chat —— 拉服务器端首屏 + 连 WS(token 在 payload)
|
||||
* - `ChatNonVipLogin`:非 VIP 用户进入 /chat —— 拉服务器端首屏,不连 WS
|
||||
* - `ChatVipLogin`:VIP 用户进入 /chat —— 拉服务器端首屏 + 连 WS(token 在 payload)
|
||||
* - `ChatLogout`:登出 / 切换用户 —— 断 WS + 清消息
|
||||
*
|
||||
* 设计:所有WS / 历史 / 配额相关副作用全部通过派发事件给 chat 机器处理,
|
||||
@@ -18,7 +19,8 @@
|
||||
export type ChatEvent =
|
||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||
| { type: "ChatGuestLogin" }
|
||||
| { type: "ChatNonGuestLogin"; token: string; isVip: boolean }
|
||||
| { type: "ChatNonVipLogin"; token: string }
|
||||
| { type: "ChatVipLogin"; token: string }
|
||||
| { type: "ChatLogout" }
|
||||
// 业务事件
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
|
||||
@@ -31,8 +31,8 @@ const log = new Logger("StoresChatChatMachineActors");
|
||||
* 为什么用 module-level 而非 context.wsRef:
|
||||
* 1) `ChatWebSocket` 是带回调 + 重连 timer 的非可序列化对象,放进
|
||||
* XState context 会污染快照、警告 non-serializable
|
||||
* 2) userSession 同一时间只可能有一个活跃 WS(parent-level invoke),
|
||||
* module 单例的语义和"userSession 内一个活跃连接"一一对应
|
||||
* 2) vipUserSession 同一时间只可能有一个活跃 WS(parent-level invoke),
|
||||
* module 单例的语义和"VIP 会话内一个活跃连接"一一对应
|
||||
* 3) 不需要重构 ChatWebSocket 类的对外 API
|
||||
*/
|
||||
let activeChatWebSocket: ChatWebSocket | null = null;
|
||||
@@ -123,7 +123,7 @@ export const loadMoreHistoryActor = fromPromise<
|
||||
// WebSocket: long-lived callback
|
||||
// ============================================================
|
||||
/**
|
||||
* 长生命周期 WebSocket(仅 `userSession` 期间 invoke)
|
||||
* 长生命周期 WebSocket(仅 `vipUserSession` 期间 invoke)
|
||||
* - 启动:连 WS,事件 → sendBack 派回机器
|
||||
* - 停止(cleanup):disconnect
|
||||
* - 游客不 invoke 此 actor(业务事实"游客不连 WS")
|
||||
@@ -170,7 +170,7 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
|
||||
// WebSocket: per-send callback
|
||||
// ============================================================
|
||||
/**
|
||||
* WS 发送消息(userSession.sendingViaWs 期间 invoke)
|
||||
* WS 发送消息(vipUserSession.sendingViaWs 期间 invoke)
|
||||
* - 走 `activeChatWebSocket.sendMessage(content)`
|
||||
* - 成功后 AI 回复通过 `chatWebSocketActor` 的 `onSentence` → `ChatAISentenceReceived` 流回
|
||||
* - 失败抛错 → state machine 的 `onError` 兜底(清 isReplyingAI、回 ready)
|
||||
|
||||
+207
-58
@@ -3,27 +3,30 @@
|
||||
*
|
||||
* 鉴权解耦(事件驱动):
|
||||
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
|
||||
* ChatAuthSync 只派 3 个事件:
|
||||
* ChatAuthSync 派发登录态生命周期事件:
|
||||
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
||||
* - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
|
||||
* - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS)
|
||||
* - `ChatVipLogin { token }` → VIP 用户会话(连 WS)
|
||||
* - `ChatLogout` → 登出(机器自动 cleanup WS actor)
|
||||
*
|
||||
* 状态结构(parent state 模式):
|
||||
* - `idle`:屏没挂 / 登出
|
||||
* - `guestSession`(parent):游客会话 —— 不 invoke WS
|
||||
* - `userSession`(parent):非游客会话 —— invoke WS(持久)
|
||||
* - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS
|
||||
* - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久)
|
||||
*
|
||||
* init 双任务(核心重构):
|
||||
* - guestSession.initializing:loadQuota + loadHistory 并行(`invoke: [...]` 数组)
|
||||
* - userSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
||||
* - nonVipUserSession.initializing:loadHistory
|
||||
* - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||
*
|
||||
* 消息发送路径(业务事实):
|
||||
* - guestSession:走 HTTP,并先检查本地游客配额
|
||||
* - userSession + VIP + WS 已连:走 WS,不受每日免费次数限制
|
||||
* - userSession + 非 VIP:走 HTTP,让后端返回 daily_limit blocked
|
||||
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked
|
||||
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
|
||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||
* - `userSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||||
*
|
||||
* 配额:
|
||||
* - 游客:保留本地配额 guard,命中后不发送消息
|
||||
@@ -75,31 +78,22 @@ export const chatMachine = setup({
|
||||
actions: {
|
||||
startGuestSession: assign(() => ({
|
||||
...initialState,
|
||||
isVip: false,
|
||||
})),
|
||||
|
||||
startUserSession: assign(({ event }) => {
|
||||
if (event.type !== "ChatNonGuestLogin") return {};
|
||||
return {
|
||||
...initialState,
|
||||
isVip: event.isVip,
|
||||
};
|
||||
}),
|
||||
startUserSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
clearChatSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
appendUserMessage: assign(({ context, event }) => {
|
||||
appendGuestUserMessage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
// 两个额度同步更新:WS 已连不递减(实时会话不消耗),HTTP 必须减
|
||||
const newRemaining = context.wsConnected
|
||||
? context.guestRemainingQuota
|
||||
: Math.max(0, context.guestRemainingQuota - 1);
|
||||
const newTotal = context.wsConnected
|
||||
? context.guestTotalQuota
|
||||
: Math.max(0, context.guestTotalQuota - 1);
|
||||
// 游客消息发送成功前先乐观扣减本地日配额 + 总配额。
|
||||
const newRemaining = Math.max(0, context.guestRemainingQuota - 1);
|
||||
const newTotal = Math.max(0, context.guestTotalQuota - 1);
|
||||
|
||||
// 持久化两个额度( fire-and-forget,不 await —— assign 是 sync)
|
||||
// daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset)
|
||||
@@ -108,10 +102,10 @@ export const chatMachine = setup({
|
||||
|
||||
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
||||
|
||||
log.debug("[chat-machine] appendUserMessage", {
|
||||
log.debug("[chat-machine] appendGuestUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
|
||||
quotaMode: "guest http (decrement)",
|
||||
oldRemaining: context.guestRemainingQuota,
|
||||
newRemaining,
|
||||
oldTotal: context.guestTotalQuota,
|
||||
@@ -137,25 +131,47 @@ export const chatMachine = setup({
|
||||
};
|
||||
}),
|
||||
|
||||
appendUserImage: assign(({ context, event }) => {
|
||||
appendUserMessage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
wsConnected: context.wsConnected,
|
||||
isReplyingAI: true,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
appendGuestUserImage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
// 同 appendUserMessage:两个额度同步减
|
||||
const newRemaining = context.wsConnected
|
||||
? context.guestRemainingQuota
|
||||
: Math.max(0, context.guestRemainingQuota - 1);
|
||||
const newTotal = context.wsConnected
|
||||
? context.guestTotalQuota
|
||||
: Math.max(0, context.guestTotalQuota - 1);
|
||||
// 同 appendGuestUserMessage:游客两个额度同步减
|
||||
const newRemaining = Math.max(0, context.guestRemainingQuota - 1);
|
||||
const newTotal = Math.max(0, context.guestTotalQuota - 1);
|
||||
|
||||
const today = todayString();
|
||||
void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
||||
|
||||
void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
||||
|
||||
log.debug("[chat-machine] appendUserImage", {
|
||||
log.debug("[chat-machine] appendGuestUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
wsConnected: context.wsConnected,
|
||||
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
|
||||
quotaMode: "guest http (decrement)",
|
||||
oldRemaining: context.guestRemainingQuota,
|
||||
newRemaining,
|
||||
oldTotal: context.guestTotalQuota,
|
||||
@@ -180,6 +196,32 @@ export const chatMachine = setup({
|
||||
};
|
||||
}),
|
||||
|
||||
appendUserImage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
wsConnected: context.wsConnected,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
imageUrl: event.imageBase64,
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
appendOrUpdateAISentence: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatAISentenceReceived") return {};
|
||||
const messages = [...context.messages];
|
||||
@@ -237,8 +279,12 @@ export const chatMachine = setup({
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
@@ -250,8 +296,12 @@ export const chatMachine = setup({
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
@@ -324,7 +374,7 @@ export const chatMachine = setup({
|
||||
},
|
||||
// 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志)
|
||||
{
|
||||
actions: "appendUserMessage",
|
||||
actions: "appendGuestUserMessage",
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
target: "sending",
|
||||
},
|
||||
@@ -340,14 +390,11 @@ export const chatMachine = setup({
|
||||
actions: "incrementQuotaExceeded",
|
||||
},
|
||||
{
|
||||
actions: "appendUserImage",
|
||||
actions: "appendGuestUserImage",
|
||||
target: "sending",
|
||||
},
|
||||
],
|
||||
// 删除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页
|
||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
},
|
||||
},
|
||||
@@ -372,11 +419,110 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
|
||||
userSession: {
|
||||
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 userSession.on
|
||||
nonVipUserSession: {
|
||||
on: {
|
||||
ChatLogout: {
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatGuestLogin: {
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
reenter: true,
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
states: {
|
||||
initializing: {
|
||||
invoke: {
|
||||
src: "loadHistory",
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
messages: event.output.messages,
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
historyLoaded: true,
|
||||
})),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({
|
||||
isLoadingMore: false,
|
||||
historyLoaded: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
target: "sendingViaHttp",
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendUserImage",
|
||||
},
|
||||
ChatLoadMoreHistory: {
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
},
|
||||
},
|
||||
sendingViaHttp: {
|
||||
invoke: {
|
||||
src: "sendMessageHttp",
|
||||
input: ({ event }) => ({
|
||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) =>
|
||||
applyHttpSendOutput(context, event.output),
|
||||
),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isReplyingAI: false }),
|
||||
},
|
||||
},
|
||||
},
|
||||
loadingMore: {
|
||||
invoke: {
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ offset: context.historyOffset }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
messages: [...event.output.messages, ...context.messages],
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
})),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isLoadingMore: false }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
vipUserSession: {
|
||||
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 vipUserSession.on
|
||||
// —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore)
|
||||
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages,
|
||||
// 这点是 sendingViaWs 流式回 reply 的关键(machine 当时在 sendingViaWs)
|
||||
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages。
|
||||
//
|
||||
// 子级 override 规则:
|
||||
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
||||
@@ -390,8 +536,12 @@ export const chatMachine = setup({
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
reenter: true,
|
||||
actions: "startUserSession",
|
||||
},
|
||||
@@ -405,7 +555,7 @@ export const chatMachine = setup({
|
||||
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore)
|
||||
src: "chatWebSocket",
|
||||
input: ({ event }) => ({
|
||||
token: event.type === "ChatNonGuestLogin" ? event.token : "",
|
||||
token: event.type === "ChatVipLogin" ? event.token : "",
|
||||
}),
|
||||
},
|
||||
states: {
|
||||
@@ -445,9 +595,8 @@ export const chatMachine = setup({
|
||||
// 内容非空;wsConnected=false 同理)
|
||||
ChatSendMessage: [
|
||||
{
|
||||
// VIP 用户保留 WS;非 VIP 走 HTTP,让后端 daily_limit 能返回 blocked。
|
||||
// VIP 用户优先走 WS;WS 尚未可用时由下一个 branch 走 HTTP fallback。
|
||||
guard: ({ context, event }) =>
|
||||
context.isVip &&
|
||||
context.wsConnected &&
|
||||
event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
@@ -468,16 +617,16 @@ export const chatMachine = setup({
|
||||
},
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||||
// 已上提到 userSession.on,子级不再声明
|
||||
// 已上提到 vipUserSession.on,子级不再声明
|
||||
},
|
||||
},
|
||||
// WS 发送:invoke sendMessageWsActor 触发一次 send,
|
||||
// 之后 AI 流式回 reply 走 userSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
|
||||
// 之后 AI 流式回 reply 走 vipUserSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
|
||||
// 当句尾 done: true → isReplyingAI = false → always 跳回 ready
|
||||
sendingViaWs: {
|
||||
on: {
|
||||
// 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs)
|
||||
// 这个 child handler 替换 userSession.on 的 ChatWebSocketError(XState v5 不合并)
|
||||
// 这个 child handler 替换 vipUserSession.on 的 ChatWebSocketError(XState v5 不合并)
|
||||
// 所以需要重复声明 action: "appendSocketErrorMessage"
|
||||
ChatWebSocketError: {
|
||||
target: "ready",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Chat 状态机:State 形状 + 初始值
|
||||
*
|
||||
* 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。
|
||||
*
|
||||
* 新增 `wsConnected: boolean`(仅 userSession 期间 true):
|
||||
* - WS 已连 → 消息发送走 WS,不消耗次数
|
||||
* - WS 未连 → 消息发送走 HTTP,消耗次数
|
||||
* `wsConnected: boolean` 仅 VIP 会话期间有意义:
|
||||
* - VIP + WS 已连 → 消息发送走 WS
|
||||
* - 非 VIP / 游客 → 不连接 WS,消息走 HTTP
|
||||
* - `ChatWebSocketConnected` 事件 → true
|
||||
* - `userSession.exit` → false(WS actor cleanup 时也会跑 exit)
|
||||
* - VIP 会话 exit → false(WS actor cleanup 时也会跑 exit)
|
||||
*/
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
|
||||
@@ -15,14 +13,11 @@ export interface ChatState {
|
||||
messages: UiMessage[];
|
||||
isReplyingAI: boolean;
|
||||
/**
|
||||
* WebSocket 连接状态(仅 userSession 期间 true)
|
||||
* - 决定消息发送路径(WS / HTTP)和是否消耗次数
|
||||
* - true:走 WS,不消耗次数
|
||||
* - false:走 HTTP,消耗次数
|
||||
* WebSocket 连接状态(仅 VIP 用户会话期间 true)
|
||||
* - VIP true:走 WS
|
||||
* - 非 VIP / 游客:固定 false,走 HTTP
|
||||
*/
|
||||
wsConnected: boolean;
|
||||
/** 当前非游客用户是否 VIP;游客会话固定 false。 */
|
||||
isVip: boolean;
|
||||
/** 游客剩余配额(次/天)—— 仅游客业务展示用
|
||||
*/
|
||||
guestRemainingQuota: number;
|
||||
@@ -37,11 +32,11 @@ export interface ChatState {
|
||||
historyOffset: number;
|
||||
/** 游客配额加载完成标志(仅 guestSession 期间) —— default false
|
||||
* - guestSession.initializing 走 always barrier:`quotaLoaded && historyLoaded` 才进 ready
|
||||
* - userSession 永不为 true(不调 loadQuota)—— OK,不影响 barrier
|
||||
* - user sessions 永不为 true(不调 loadQuota)—— OK,不影响 barrier
|
||||
*/
|
||||
quotaLoaded: boolean;
|
||||
/** history 加载完成标志(initial load —— local → network → save 跑完)
|
||||
* - guestSession.initializing / userSession.initializing 走 always barrier
|
||||
* - guestSession.initializing / user sessions initializing 走 always barrier
|
||||
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
|
||||
*/
|
||||
historyLoaded: boolean;
|
||||
@@ -51,7 +46,6 @@ export const initialState: ChatState = {
|
||||
messages: [],
|
||||
isReplyingAI: false,
|
||||
wsConnected: false,
|
||||
isVip: false,
|
||||
guestRemainingQuota: 0,
|
||||
guestTotalQuota: 0,
|
||||
quotaExceededTrigger: 0,
|
||||
|
||||
@@ -108,6 +108,7 @@ export const paymentMachine = setup({
|
||||
},
|
||||
PaymentPayChannelChanged: {
|
||||
actions: assign(({ event }) => ({
|
||||
...resetOrderState(),
|
||||
payChannel: event.payChannel,
|
||||
errorMessage: null,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user