centralize mud balance state and fix 晚返回

This commit is contained in:
2026-07-17 17:13:17 +08:00
parent 832b3769cc
commit 2efc740f8f
6 changed files with 379 additions and 115 deletions
+64 -81
View File
@@ -75,8 +75,6 @@ import {
selectGameCreationAppReadyTasks,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import type {
ProfileDashboardSummary,
ProfileMudPointBalance,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileWalletLedgerResponse,
@@ -89,10 +87,10 @@ import {
import {
confirmClientWechatProfileRechargeOrder,
createClientProfileRechargeOrder,
getClientProfileDashboard,
getClientProfileRechargeCenter,
getClientProfileWalletLedger,
} from './services/clientApi';
import { useWalletStore } from './stores/useWalletStore';
import HomeView, {
type HomeAgentMode,
type HomeAgentModeItem,
@@ -108,6 +106,10 @@ const seedManifest = createGameCreationAppManifest(
);
const defaultProjectPath = '/tmp/genarrative-ai-game-draft';
type RechargeContent = Omit<
ProfileRechargeCenterResponse,
'walletBalance' | 'mudPointBalance'
>;
const RECENT_WORKSPACES_STORAGE_KEY =
'genarrative-ai-game-creator.recent-workspaces.v1';
const AGENT_RUN_HISTORY_MAX_COUNT = 100;
@@ -5360,14 +5362,14 @@ export function WorkspaceLauncher({
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
const [pendingNonEmptyProject, setPendingNonEmptyProject] =
useState<PendingNonEmptyProject | null>(null);
const [profileDashboard, setProfileDashboard] =
useState<ProfileDashboardSummary | null>(null);
const [mudPointBalance, setMudPointBalance] =
useState<ProfileMudPointBalance | null>(null);
const [mudPointBalanceStatus, setMudPointBalanceStatus] = useState<
'idle' | 'loading' | 'ready' | 'error'
>('idle');
const [mudPointBalanceError, setMudPointBalanceError] = useState('');
const {
mudPointBalance,
mudPointBalanceStatus,
mudPointBalanceError,
applyWalletBalanceSnapshot,
onWalletBalanceMayHaveChanged,
resetWalletBalance,
} = useWalletStore();
const [walletLedgerOpen, setWalletLedgerOpen] = useState(false);
const [walletLedger, setWalletLedger] =
useState<ProfileWalletLedgerResponse | null>(null);
@@ -5376,8 +5378,8 @@ export function WorkspaceLauncher({
null,
);
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeCenter, setRechargeCenter] =
useState<ProfileRechargeCenterResponse | null>(null);
const [rechargeContent, setRechargeContent] =
useState<RechargeContent | null>(null);
const [rechargeLoading, setRechargeLoading] = useState(false);
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
@@ -5385,6 +5387,14 @@ export function WorkspaceLauncher({
const [nativeRechargePayment, setNativeRechargePayment] =
useState<PlatformProfileRechargeNativePaymentState | null>(null);
const rechargeLifecycleRef = useRef(0);
const rechargeModalCenter =
rechargeContent && mudPointBalance
? {
...rechargeContent,
walletBalance: mudPointBalance.totalPoints,
mudPointBalance,
}
: null;
const [launcherNotice, setLauncherNotice] = useState<{
title: string;
message: string;
@@ -5472,6 +5482,23 @@ export function WorkspaceLauncher({
setAgentChatRunSubmitMode('steer');
}, [agentChatSelectedAgentId, agentChatSelectedSessionId]);
useEffect(() => {
resetWalletBalance();
void onWalletBalanceMayHaveChanged();
const refreshWalletBalance = () => {
void onWalletBalanceMayHaveChanged();
};
window.addEventListener('focus', refreshWalletBalance);
return () => {
window.removeEventListener('focus', refreshWalletBalance);
};
}, [
currentUser.id,
onWalletBalanceMayHaveChanged,
resetWalletBalance,
]);
function setAgentChatPendingRuntimeRun(
pendingRun: AgentChatPendingRuntimeRun | null,
) {
@@ -5828,29 +5855,6 @@ export function WorkspaceLauncher({
};
}, [agentChatPendingRuntimeRun]);
useEffect(() => {
let disposed = false;
void getClientProfileDashboard()
.then((dashboard) => {
if (disposed) {
return;
}
setProfileDashboard(dashboard);
})
.catch((error) => {
if (disposed) {
return;
}
setProfileDashboard(null);
setMudPointBalanceError(
error instanceof Error ? error.message : '泥点读取失败',
);
});
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (launcherView !== 'agent-chat') {
return;
@@ -6189,28 +6193,6 @@ export function WorkspaceLauncher({
);
}
async function loadMudPointBalance() {
if (mudPointBalanceStatus === 'loading') {
return;
}
setMudPointBalanceStatus('loading');
setMudPointBalanceError('');
try {
const center = await getClientProfileRechargeCenter();
setMudPointBalance(center.mudPointBalance ?? null);
setProfileDashboard((current) =>
current ? { ...current, walletBalance: center.walletBalance } : current,
);
setMudPointBalanceStatus('ready');
} catch (error) {
setMudPointBalance(null);
setMudPointBalanceStatus('error');
setMudPointBalanceError(
error instanceof Error ? error.message : '泥点明细读取失败',
);
}
}
async function loadWalletLedger() {
setWalletLedgerLoading(true);
setWalletLedgerError(null);
@@ -6231,14 +6213,13 @@ export function WorkspaceLauncher({
void loadWalletLedger();
}
function applyRechargeCenter(center: ProfileRechargeCenterResponse) {
setRechargeCenter(center);
setMudPointBalance(center.mudPointBalance ?? null);
setMudPointBalanceStatus('ready');
setMudPointBalanceError('');
setProfileDashboard((current) =>
current ? { ...current, walletBalance: center.walletBalance } : current,
);
function applyRechargeContent(center: ProfileRechargeCenterResponse) {
const { walletBalance, mudPointBalance, ...content } = center;
void walletBalance;
if (mudPointBalance) {
applyWalletBalanceSnapshot(mudPointBalance);
}
setRechargeContent(content);
}
async function loadRechargeCenter() {
@@ -6250,7 +6231,7 @@ export function WorkspaceLauncher({
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
return;
}
applyRechargeCenter(center);
applyRechargeContent(center);
} catch (error) {
if (rechargeLifecycleRef.current === rechargeLifecycle) {
setRechargeError(
@@ -6271,6 +6252,13 @@ export function WorkspaceLauncher({
void loadRechargeCenter();
}
function closeRecharge() {
rechargeLifecycleRef.current += 1;
setRechargeOpen(false);
setNativeRechargePayment(null);
setSubmittingRechargeProductId(null);
}
async function buyRechargeProduct(product: ProfileRechargeProduct) {
if (submittingRechargeProductId) {
return;
@@ -6285,7 +6273,7 @@ export function WorkspaceLauncher({
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
return;
}
applyRechargeCenter(response.center);
applyRechargeContent(response.center);
const nativePayment = response.wechatNativePayment;
const codeUrl = nativePayment?.codeUrl?.trim();
const expiresAt = nativePayment?.expiresAt?.trim();
@@ -6327,9 +6315,10 @@ export function WorkspaceLauncher({
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
return;
}
applyRechargeCenter(response.center);
applyRechargeContent(response.center);
if (response.order.status === 'paid') {
setNativeRechargePayment(null);
void onWalletBalanceMayHaveChanged();
return;
}
const confirmMessage =
@@ -8654,6 +8643,7 @@ export function WorkspaceLauncher({
currentUser={currentUser}
onLogout={() => {
resetLauncherHomeDraft();
resetWalletBalance();
onLogout();
}}
onNoticeRequest={showLauncherNotice}
@@ -8682,11 +8672,11 @@ export function WorkspaceLauncher({
<div className="launcher-account-bar" aria-label="账户资产">
<PlatformMudPointWalletEntry
balance={profileDashboard?.walletBalance ?? null}
balance={mudPointBalance?.totalPoints ?? null}
breakdown={mudPointBalance}
isLoading={mudPointBalanceStatus === 'loading'}
error={mudPointBalanceError || null}
onRequestDetails={() => void loadMudPointBalance()}
onRequestDetails={() => void onWalletBalanceMayHaveChanged()}
onRecharge={openRecharge}
onOpenLedger={openWalletLedger}
/>
@@ -9447,17 +9437,12 @@ export function WorkspaceLauncher({
) : null}
{rechargeOpen ? (
<PlatformProfileRechargeModal
center={rechargeCenter}
center={rechargeModalCenter}
isLoading={rechargeLoading}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeRechargePayment}
onClose={() => {
rechargeLifecycleRef.current += 1;
setRechargeOpen(false);
setNativeRechargePayment(null);
setSubmittingRechargeProductId(null);
}}
onClose={closeRecharge}
onRetry={() => void loadRechargeCenter()}
onBuy={(product) => void buyRechargeProduct(product)}
onConfirmNativePayment={() => void confirmNativeRechargePayment()}
@@ -9467,9 +9452,7 @@ export function WorkspaceLauncher({
{walletLedgerOpen ? (
<PlatformProfileWalletLedgerModal
ledger={walletLedger}
fallbackBalance={
profileDashboard?.walletBalance ?? mudPointBalance?.totalPoints ?? 0
}
fallbackBalance={mudPointBalance?.totalPoints ?? 0}
isLoading={walletLedgerLoading}
error={walletLedgerError}
onClose={() => setWalletLedgerOpen(false)}
@@ -0,0 +1,100 @@
import { create } from 'zustand';
import type { ProfileMudPointBalance } from '../../../../packages/shared/src';
import { getClientProfileRechargeCenter } from '../services/clientApi';
export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error';
type WalletStore = {
mudPointBalance: ProfileMudPointBalance | null;
mudPointBalanceStatus: MudPointBalanceStatus;
mudPointBalanceError: string;
applyWalletBalanceSnapshot: (balance: ProfileMudPointBalance) => void;
onWalletBalanceMayHaveChanged: () => Promise<void>;
resetWalletBalance: () => void;
};
const initialWalletState: Pick<
WalletStore,
'mudPointBalance' | 'mudPointBalanceStatus' | 'mudPointBalanceError'
> = {
mudPointBalance: null,
mudPointBalanceStatus: 'idle',
mudPointBalanceError: '',
};
let refreshVersion = 0;
let requestGeneration = 0;
let activeRefresh: Promise<void> | null = null;
export const useWalletStore = create<WalletStore>((set) => ({
...initialWalletState,
applyWalletBalanceSnapshot: (balance) => {
requestGeneration += 1;
refreshVersion = 0;
activeRefresh = null;
set({
mudPointBalance: balance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
},
onWalletBalanceMayHaveChanged: () => {
refreshVersion += 1;
if (activeRefresh) {
return activeRefresh;
}
const refreshRequestGeneration = requestGeneration;
const refresh = (async () => {
while (refreshRequestGeneration === requestGeneration) {
const requestedVersion = refreshVersion;
set({ mudPointBalanceStatus: 'loading', mudPointBalanceError: '' });
try {
const center = await getClientProfileRechargeCenter();
if (!center.mudPointBalance) {
throw new Error('充值中心响应缺少泥点余额');
}
if (refreshRequestGeneration !== requestGeneration) {
return;
}
if (requestedVersion !== refreshVersion) {
continue;
}
set({
mudPointBalance: center.mudPointBalance,
mudPointBalanceStatus: 'ready',
mudPointBalanceError: '',
});
return;
} catch (error) {
if (refreshRequestGeneration !== requestGeneration) {
return;
}
if (requestedVersion !== refreshVersion) {
continue;
}
set({
mudPointBalanceStatus: 'error',
mudPointBalanceError:
error instanceof Error ? error.message : '泥点明细读取失败',
});
return;
}
}
})();
activeRefresh = refresh;
void refresh.finally(() => {
if (activeRefresh === refresh) {
activeRefresh = null;
}
});
return refresh;
},
resetWalletBalance: () => {
requestGeneration += 1;
refreshVersion = 0;
activeRefresh = null;
set(initialWalletState);
},
}));
+13 -28
View File
@@ -10,7 +10,7 @@ import {
import { useEffect, useRef, useState } from 'react';
import BRAND_ICON from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { getClientProfileDashboard } from '../services/clientApi';
import { useWalletStore } from '../stores/useWalletStore';
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
@@ -70,33 +70,18 @@ function SidebarAccountMenu({
onNoticeRequest,
onRechargeRequest,
}: SidebarAccountMenuProps) {
const [walletBalanceLabel, setWalletBalanceLabel] = useState('--');
const [walletBalanceStatus, setWalletBalanceStatus] =
useState('正在读取泥点余额');
useEffect(() => {
let disposed = false;
void getClientProfileDashboard()
.then((dashboard) => {
if (disposed) {
return;
}
setWalletBalanceLabel(formatMudPoints(dashboard.walletBalance));
setWalletBalanceStatus('泥点余额已读取');
})
.catch((error) => {
if (disposed) {
return;
}
setWalletBalanceLabel('--');
setWalletBalanceStatus(
error instanceof Error ? error.message : '泥点余额读取失败',
);
});
return () => {
disposed = true;
};
}, []);
const walletBalance = useWalletStore(
(state) => state.mudPointBalance?.totalPoints ?? null,
);
const walletBalanceError = useWalletStore(
(state) => state.mudPointBalanceError || null,
);
const walletBalanceLabel = formatMudPoints(walletBalance);
const walletBalanceStatus = walletBalanceError
? walletBalanceError
: walletBalance !== null
? '泥点余额已读取'
: '正在读取泥点余额';
function showNotice(title: string) {
onClose();
@@ -1630,18 +1630,29 @@ describe('AI 游戏创作 App 界面边界', () => {
hasPointsRecharged: true,
};
let rechargeOrderRequestCount = 0;
let rechargePaid = false;
let releaseLateRechargeOrder: (() => void) | null = null;
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/profile/dashboard') {
return new Response(JSON.stringify({ walletBalance: 120 }), {
status: 200,
});
}
if (url === '/api/profile/recharge-center') {
return new Response(JSON.stringify(rechargeCenter), { status: 200 });
return new Response(
JSON.stringify(
rechargePaid
? {
...rechargeCenter,
walletBalance: 180,
mudPointBalance: {
...rechargeCenter.mudPointBalance,
totalPoints: 180,
permanentPoints: 160,
},
}
: rechargeCenter,
),
{ status: 200 },
);
}
if (url === '/api/profile/recharge/orders') {
rechargeOrderRequestCount += 1;
@@ -1678,6 +1689,7 @@ describe('AI 游戏创作 App 界面边界', () => {
if (
url === '/api/profile/recharge/orders/order-native-1/wechat/confirm'
) {
rechargePaid = true;
return new Response(
JSON.stringify({
order: {
@@ -0,0 +1,182 @@
/** @vitest-environment jsdom */
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const clientApi = vi.hoisted(() => ({
getClientProfileRechargeCenter: vi.fn(),
}));
vi.mock('../src/services/clientApi', () => clientApi);
import { useWalletStore } from '../src/stores/useWalletStore';
function detailedBalance(totalPoints: number) {
return {
totalPoints,
permanentPoints: Math.max(0, totalPoints - 20),
limitedPoints: 20,
limitedExpiresAt: '2026-08-01T00:00:00Z',
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-18T00:00:00Z',
};
}
describe('useWalletStore', () => {
beforeEach(() => {
useWalletStore.getState().resetWalletBalance();
vi.clearAllMocks();
});
test('keeps mudPointBalance as the only balance state', async () => {
const mudPointBalance = detailedBalance(120);
clientApi.getClientProfileRechargeCenter.mockResolvedValue({
walletBalance: 120,
mudPointBalance,
});
const { result } = renderHook(() => useWalletStore());
await act(async () => {
await result.current.onWalletBalanceMayHaveChanged();
});
expect(result.current.mudPointBalance).toEqual(mudPointBalance);
expect(result.current.mudPointBalanceStatus).toBe('ready');
expect(result.current.mudPointBalanceError).toBe('');
expect(Object.keys(result.current).sort()).toEqual([
'applyWalletBalanceSnapshot',
'mudPointBalance',
'mudPointBalanceError',
'mudPointBalanceStatus',
'onWalletBalanceMayHaveChanged',
'resetWalletBalance',
]);
});
test('recovers from a failed refresh with a recharge-center snapshot', async () => {
clientApi.getClientProfileRechargeCenter.mockRejectedValueOnce(
new Error('首次读取失败'),
);
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
const mudPointBalance = detailedBalance(120);
useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance);
expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance);
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready');
expect(useWalletStore.getState().mudPointBalanceError).toBe('');
});
test('does not let an older refresh overwrite an applied snapshot', async () => {
let rejectRequest: ((reason: Error) => void) | undefined;
clientApi.getClientProfileRechargeCenter.mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectRequest = reject;
}),
);
const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged();
const mudPointBalance = detailedBalance(160);
useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance);
rejectRequest?.(new Error('过期请求失败'));
await refresh;
expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance);
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready');
expect(useWalletStore.getState().mudPointBalanceError).toBe('');
});
test('rejects a response that omits mudPointBalance instead of inventing a breakdown', async () => {
clientApi.getClientProfileRechargeCenter.mockResolvedValue({
walletBalance: 120,
});
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
expect(useWalletStore.getState().mudPointBalance).toBeNull();
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error');
expect(useWalletStore.getState().mudPointBalanceError).toBe(
'充值中心响应缺少泥点余额',
);
});
test('uses mudPointBalance from recharge-center as the authoritative snapshot', async () => {
const mudPointBalance = detailedBalance(180);
clientApi.getClientProfileRechargeCenter.mockResolvedValue({
walletBalance: 999,
mudPointBalance,
});
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance);
});
test('discards an outdated response and performs one trailing refresh', async () => {
let resolveFirst: ((value: unknown) => void) | undefined;
clientApi.getClientProfileRechargeCenter
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce({
walletBalance: 80,
mudPointBalance: detailedBalance(80),
});
const firstRefresh =
useWalletStore.getState().onWalletBalanceMayHaveChanged();
const trailingRefresh =
useWalletStore.getState().onWalletBalanceMayHaveChanged();
resolveFirst?.({
walletBalance: 120,
mudPointBalance: detailedBalance(120),
});
await Promise.all([firstRefresh, trailingRefresh]);
expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(2);
expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(80);
});
test('retains the last balance when a refresh fails', async () => {
clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({
walletBalance: 90,
mudPointBalance: detailedBalance(90),
});
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
clientApi.getClientProfileRechargeCenter.mockRejectedValueOnce(
new Error('刷新失败'),
);
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(90);
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error');
expect(useWalletStore.getState().mudPointBalanceError).toBe('刷新失败');
});
test('does not restore a late response after reset', async () => {
let resolveRequest: ((value: unknown) => void) | undefined;
clientApi.getClientProfileRechargeCenter.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRequest = resolve;
}),
);
const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged();
useWalletStore.getState().resetWalletBalance();
resolveRequest?.({
walletBalance: 75,
mudPointBalance: detailedBalance(75),
});
await refresh;
expect(useWalletStore.getState().mudPointBalance).toBeNull();
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('idle');
});
});
@@ -88,6 +88,8 @@ V1.18 开发窗口把模式扩展为 `执行 / 聊天 / 目标`,Goal 创建和
以下能力清单保留 Runtime V1 的演进记录;其中“App 进程内 tokio task”“跨进程同项目写入不作为支持目标”和“恢复到当前 App 进程”的旧描述均已由 V1.1 替代。当前边界是 App / CLI 只落账并唤醒同一发布二进制的独立 Runnerappend-only JSONL 使用进程内锁加 OS 文件锁,恢复继续由 Runner 接管同一 run / session。
客户端泥点余额统一由 `apps/ai-game-creator-shell/src/stores/useWalletStore.ts` Zustand store 持有,唯一余额真相是 `mudPointBalance``ProfileDashboardSummary.walletBalance` 不作为余额来源;所有可能改变余额的动作统一调用 `onWalletBalanceMayHaveChanged()` 从 recharge-center 完整刷新,不在客户端本地增减余额。账单请求、充值产品内容、下单 / 支付确认和弹窗状态留在使用它们的组件,侧栏账户菜单直接订阅该 store,不通过布局 props 传递余额。
Agent Runtime 负责:
- 当前执行边界:App / CLI 只负责 durable 入队、查询和唤醒;同一发布二进制的独立 Runner 取得项目 owner 与 per-Agent OS 锁后执行 loop。Agent DB、conversation、events、tasks、activity 和 output 的 append-only JSONL 同时使用进程内互斥与 OS 文件锁,恢复由 Runner 接管原 run / session,不再重接到当前 App 进程。