合并远端 AI 游戏创作分支改动

同步首页侧栏与富文本输入改造
保留当前 GameAgent 工作台和自主 Runtime 改动
This commit is contained in:
AIGameCreator App
2026-07-21 11:46:38 +08:00
47 changed files with 4204 additions and 1367 deletions
+5
View File
@@ -26,7 +26,12 @@
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
},
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+326 -2
View File
@@ -65,6 +65,27 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -378,6 +399,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.0"
@@ -599,6 +626,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -720,6 +756,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -967,7 +1009,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser",
"foldhash",
"foldhash 0.2.0",
"html5ever",
"precomputed-hash",
"selectors",
@@ -1120,6 +1162,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "euclid"
version = "0.20.14"
@@ -1156,6 +1204,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1192,6 +1246,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1208,6 +1268,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1493,6 +1559,7 @@ dependencies = [
"similar",
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tempfile",
@@ -1513,6 +1580,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1719,12 +1796,32 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -2087,6 +2184,20 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2386,7 +2497,7 @@ dependencies = [
"itoa",
"log",
"md-5",
"nom",
"nom 7.1.3",
"rangemap",
"time",
"weezl",
@@ -2477,6 +2588,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.19.3"
@@ -2579,6 +2700,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2636,6 +2766,7 @@ dependencies = [
"block2",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
]
@@ -2889,6 +3020,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2970,6 +3111,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.14.0",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -3261,6 +3413,18 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -4579,6 +4743,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
dependencies = [
"arboard",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
@@ -4806,6 +4985,20 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.51"
@@ -5151,6 +5344,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -5488,6 +5692,76 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.13.0",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.102"
@@ -6236,6 +6510,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.3"
@@ -6307,6 +6599,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "yoke"
version = "0.8.3"
@@ -6506,6 +6815,21 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.12.0"
@@ -25,12 +25,13 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-opener = "2.5.4"
tauri-plugin-opener = "2"
tempfile = "3"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "sync", "time"] }
url = "2"
unicode-normalization = "0.1"
zip = { version = "2", default-features = false, features = ["deflate"] }
tauri-plugin-clipboard-manager = "2.3.2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text",
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
"opener:default"
]
}
@@ -1655,6 +1655,7 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(game_creator_preview_registry())
.setup(|app| {
configure_game_creator_runtime_config_dir(app.handle())?;
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,10 @@
import {
API_RESPONSE_ENVELOPE_HEADER,
API_RESPONSE_ENVELOPE_VERSION,
ConfirmWechatProfileRechargeOrderResponse,
CreateProfileRechargeOrderResponse,
ProfileDashboardSummary,
ProfileRechargeCenterResponse,
ProfileWalletLedgerResponse,
unwrapApiResponse,
} from '../../../../packages/shared/src';
@@ -134,11 +137,42 @@ export function getClientProfileDashboard() {
);
}
export function getClientProfileRechargeCenter() {
return requestClientApi<ProfileRechargeCenterResponse>(
'/api/profile/recharge-center',
{ method: 'GET' },
'读取泥点明细失败',
);
}
export function createClientProfileRechargeOrder(productId: string) {
return requestClientApi<CreateProfileRechargeOrderResponse>(
'/api/profile/recharge/orders',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId,
paymentChannel: 'wechat_native',
}),
},
'充值失败',
);
}
export function confirmClientWechatProfileRechargeOrder(orderId: string) {
return requestClientApi<ConfirmWechatProfileRechargeOrderResponse>(
`/api/profile/recharge/orders/${encodeURIComponent(orderId)}/wechat/confirm`,
{ method: 'POST' },
'确认微信支付订单失败',
);
}
export function getClientProfileWalletLedger() {
return requestClientApi<ProfileWalletLedgerResponse>(
'/api/profile/wallet-ledger',
{ method: 'GET' },
'读取泥点明细失败',
'读取泥点账单失败',
);
}
@@ -1,44 +0,0 @@
import { create } from 'zustand';
export type HomeAgentMode = 'game' | 'art' | 'doc';
export type HomeAttachmentDraft = {
id: string;
file: File;
};
export type HomeDraft = {
mode: HomeAgentMode;
prompt: string;
attachments: HomeAttachmentDraft[];
};
type UseHomeDraftStore = HomeDraft & {
setMode: (mode: HomeAgentMode) => void;
setPrompt: (prompt: string) => void;
addAttachments: (attachments: HomeAttachmentDraft[]) => void;
removeAttachment: (attachmentId: string) => void;
reset: () => void;
};
const initialHomeDraft: HomeDraft = {
mode: 'game',
prompt: '',
attachments: [],
};
// Keep non-serializable File objects available while the Home view is unmounted.
export const useLauncherHomeDraftStore = create<UseHomeDraftStore>((set) => ({
...initialHomeDraft,
setMode: (mode) => set({ mode }),
setPrompt: (prompt) => set({ prompt }),
addAttachments: (attachments) =>
set((state) => ({ attachments: [...state.attachments, ...attachments] })),
removeAttachment: (attachmentId) =>
set((state) => ({
attachments: state.attachments.filter(
(attachment) => attachment.id !== attachmentId,
),
})),
reset: () => set(initialHomeDraft),
}));
@@ -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);
},
}));
+19 -247
View File
@@ -1,4 +1,6 @@
@import 'tailwindcss';
@source '../../../packages/shared/src/components';
@import '../../../packages/shared/src/theme.css';
:root {
color: #18202f;
@@ -142,146 +144,43 @@ textarea {
}
.launcher-shell {
--launcher-sidebar-width: 64px;
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
grid-template-columns: var(--launcher-sidebar-width) minmax(0, 1fr);
min-height: 100vh;
background: #fff;
color: #171717;
}
.launcher-sidebar {
position: sticky;
top: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
height: 100vh;
padding: 16px 0 18px;
border-right: 1px solid #eceef2;
background: #fff;
overflow: visible;
.launcher-shell.platform-theme {
background: var(--platform-body-fill);
color: var(--platform-text-strong);
}
.launcher-brand {
display: grid;
place-items: center;
.launcher-shell.platform-theme .launcher-main {
background: var(--platform-body-fill);
}
.launcher-logo {
display: grid;
width: 24px;
height: 24px;
border-radius: 50%;
background: #101010;
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
place-items: center;
.launcher-shell.platform-theme .launcher-promo {
background: var(--platform-warm-bg);
color: var(--platform-warm-text);
}
.launcher-sidebar nav {
display: grid;
gap: 13px;
justify-items: center;
.launcher-shell.platform-theme .launcher-promo button {
color: var(--platform-warm-text);
}
.launcher-sidebar-bottom {
position: relative;
z-index: 60;
display: grid;
justify-items: center;
gap: 13px;
margin-top: auto;
.launcher-shell.platform-theme .launcher-countdown {
border-color: var(--platform-subpanel-border);
background: var(--platform-subpanel-fill);
color: var(--platform-text-strong);
}
.launcher-sidebar button,
.launcher-promo button,
.launcher-account-bar button {
.launcher-promo button {
border: 0;
cursor: pointer;
}
.launcher-sidebar nav button,
.launcher-config-button {
display: grid;
width: 28px;
height: 28px;
padding: 0;
border-radius: 8px;
background: transparent;
color: #161616;
font-size: 17px;
place-items: center;
}
.launcher-sidebar .launcher-nav-active {
background: #f3f4f6;
}
.launcher-account-nav,
.launcher-help-nav {
position: relative;
}
.launcher-help-menu {
position: absolute;
top: auto;
bottom: -4px;
left: 36px;
display: grid;
min-width: 150px;
gap: 5px;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
box-shadow: 0 14px 34px rgb(15 23 42 / 14%);
z-index: 90;
}
.launcher-sidebar .launcher-help-menu button,
.launcher-sidebar .launcher-account-popover button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
height: 30px;
padding: 0 9px;
border-radius: 7px;
background: transparent;
color: #1f2937;
font-size: 12px;
white-space: nowrap;
}
.launcher-sidebar .launcher-help-menu button:hover,
.launcher-sidebar .launcher-help-menu button:focus-visible,
.launcher-sidebar .launcher-account-popover button:hover,
.launcher-sidebar .launcher-account-popover button:focus-visible {
background: #f3f4f6;
}
.launcher-config-button {
font-size: 15px;
}
.launcher-config-button:hover {
background: #f3f4f6;
}
.launcher-help-nav .launcher-config-button,
.launcher-help-nav .launcher-config-button:hover,
.launcher-help-nav .launcher-config-button:focus,
.launcher-help-nav .launcher-config-button:focus-visible,
.launcher-help-nav .launcher-config-button:active,
.launcher-help-nav .launcher-config-button[aria-expanded='true'] {
background: transparent !important;
}
.launcher-main {
position: relative;
display: grid;
@@ -343,129 +242,6 @@ textarea {
top: 44px;
}
.launcher-account-bar button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 26px;
padding: 0 9px;
border: 0;
border-radius: 0;
background: #fff;
color: #1f2937;
font-size: 12px;
}
.launcher-wallet-group {
display: inline-flex;
align-items: center;
overflow: visible;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
}
.launcher-wallet-group > button,
.launcher-wallet-group .launcher-wallet-anchor > button {
border: 0;
border-radius: 0;
}
.launcher-wallet-group .launcher-wallet-anchor > button {
border-radius: 8px 0 0 8px;
font-weight: 500;
}
.launcher-wallet-group > button {
border-left: 1px solid #e5e7eb;
border-radius: 0 8px 8px 0;
}
.launcher-wallet-anchor {
position: relative;
display: flex;
}
.launcher-account-popover,
.launcher-wallet-popover {
position: absolute;
top: 34px;
right: 0;
display: grid;
width: 220px;
gap: 7px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
box-shadow: 0 16px 38px rgb(15 23 42 / 14%);
color: #111827;
z-index: 90;
}
.launcher-account-nav .launcher-account-popover {
top: auto;
right: auto;
bottom: -4px;
left: 36px;
width: 218px;
gap: 6px;
padding: 10px;
}
.launcher-account-popover strong,
.launcher-wallet-popover strong {
font-size: 13px;
}
.launcher-account-popover span,
.launcher-wallet-popover span,
.launcher-account-popover small,
.launcher-wallet-popover small {
color: #6b7280;
font-size: 11px;
}
.launcher-account-popover button,
.launcher-wallet-popover button {
justify-content: flex-start;
width: 100%;
height: 30px;
padding: 0 8px;
border: 0;
background: transparent;
color: #111827;
}
.launcher-wallet-popover p {
margin: 2px 0 4px;
font-size: 20px;
font-weight: 750;
}
.launcher-wallet-ledger {
display: grid;
gap: 5px;
max-height: 132px;
overflow: auto;
}
.launcher-wallet-ledger div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.launcher-wallet-ledger div span {
color: #111827;
font-size: 12px;
font-weight: 700;
}
.launcher-project-list-actions {
display: flex;
align-items: center;
@@ -2147,10 +1923,6 @@ textarea {
}
@media (max-width: 760px) {
.launcher-shell {
grid-template-columns: 48px minmax(0, 1fr);
}
.launcher-main {
grid-template-rows: auto auto auto;
overflow: visible;
@@ -0,0 +1,55 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getNodeByKey, type NodeKey } from 'lexical';
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { HomeAttachmentDraft } from '../../useHomeDraftStore';
export function AttachmentToken({
attachment,
nodeKey,
}: {
attachment: HomeAttachmentDraft;
nodeKey: NodeKey;
}) {
const [editor] = useLexicalComposerContext();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (!attachment.file.type.startsWith('image/')) {
setPreviewUrl(null);
return;
}
const objectUrl = URL.createObjectURL(attachment.file);
setPreviewUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [attachment.file]);
return (
<span className="group/attachment relative mx-0.75 inline-flex min-h-6 max-w-full items-center gap-1 rounded-[7px] border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-1.75 align-middle text-[11px] text-(--platform-neutral-text)">
{previewUrl ? (
<span className="pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-20 hidden max-h-60 w-max max-w-60 -translate-x-1/2 overflow-hidden rounded-[7px] border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) p-1 shadow-lg group-hover/attachment:block group-focus-within/attachment:block">
<img
className="block max-h-56 max-w-56 object-contain"
src={previewUrl}
alt={attachment.file.name}
/>
</span>
) : null}
{attachment.file.name}
<button
className="grid size-4.5 cursor-pointer place-items-center border-0 bg-transparent p-0 text-(--platform-text-soft)"
type="button"
aria-label={`移除附件 ${attachment.file.name}`}
onClick={() => {
editor.update(() => {
$getNodeByKey(nodeKey)?.remove();
});
}}
>
<X size={12} aria-hidden="true" />
</button>
</span>
);
}
@@ -0,0 +1,84 @@
import {
$applyNodeReplacement,
DecoratorNode,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type Spread,
} from 'lexical';
import type { ReactNode } from 'react';
import type { HomeAttachmentDraft } from '../../useHomeDraftStore';
import { AttachmentToken } from './attachment';
export type SerializedAttachmentNode = Spread<
{
attachment: HomeAttachmentDraft;
type: 'home-attachment';
version: 1;
},
SerializedLexicalNode
>;
export class AttachmentNode extends DecoratorNode<ReactNode> {
__attachment: HomeAttachmentDraft;
static getType() {
return 'home-attachment';
}
static clone(node: AttachmentNode) {
return new AttachmentNode(node.__attachment, node.__key);
}
static importJSON(serializedNode: SerializedAttachmentNode) {
return $createAttachmentNode(serializedNode.attachment);
}
constructor(attachment: HomeAttachmentDraft, key?: NodeKey) {
super(key);
this.__attachment = attachment;
}
exportJSON(): SerializedAttachmentNode {
return {
...super.exportJSON(),
attachment: this.__attachment,
type: 'home-attachment',
version: 1,
};
}
createDOM(_config: EditorConfig) {
return document.createElement('span');
}
updateDOM() {
return false;
}
getTextContent() {
return '';
}
isInline() {
return true;
}
decorate() {
return (
<AttachmentToken attachment={this.__attachment} nodeKey={this.__key} />
);
}
}
export function $createAttachmentNode(attachment: HomeAttachmentDraft) {
return $applyNodeReplacement(new AttachmentNode(attachment));
}
export function $isAttachmentNode(
node: LexicalNode | null | undefined,
): node is AttachmentNode {
return node instanceof AttachmentNode;
}
@@ -0,0 +1,245 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager';
import {
$createParagraphNode,
$getRoot,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH,
createCommand,
type LexicalCommand,
PASTE_COMMAND,
} from 'lexical';
import { Upload } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore';
import {
$createAttachmentNode,
AttachmentNode,
} from './attachmentNode';
type RichInputAreaProps = {
value: Draft;
placeholder: string;
onChange: (value: Draft) => void;
children?: React.ReactNode;
};
const INSERT_ATTACHMENTS_COMMAND: LexicalCommand<HomeAttachmentDraft[]> =
createCommand('INSERT_HOME_ATTACHMENTS_COMMAND');
const INSERT_CLIPBOARD_TEXT_COMMAND: LexicalCommand<string> = createCommand(
'INSERT_HOME_CLIPBOARD_TEXT_COMMAND',
);
function createAttachmentDrafts(files: readonly File[]) {
const now = Date.now();
return files.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}:${now}`,
file,
}));
}
async function readNativeClipboardImageFile() {
// this used Tauri native api, unavailable in browser env.
let image;
try {
image = await readImage();
const [{ width, height }, rgba] = await Promise.all([
image.size(),
image.rgba(),
]);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
context.putImageData(
new ImageData(new Uint8ClampedArray(rgba), width, height),
0,
0,
);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/png'),
);
if (!blob) {
return null;
}
return new File([blob], `pasted-image-${Date.now()}.png`, {
type: 'image/png',
});
} catch {
return null;
} finally {
await image?.close();
}
}
async function readNativeClipboardText() {
try {
return await readText();
} catch {
return '';
}
}
function selectEditableEndWhenNeeded() {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
return;
}
const root = $getRoot();
if (root.getChildrenSize() === 0) {
root.append($createParagraphNode());
}
root.selectEnd();
}
function EditorPlugins({
onChange,
}: Pick<RichInputAreaProps, 'onChange'>) {
const [editor] = useLexicalComposerContext();
useEffect(
() =>
editor.registerCommand(
INSERT_ATTACHMENTS_COMMAND,
(nextAttachments) => {
selectEditableEndWhenNeeded();
const currentSelection = $getSelection();
if ($isRangeSelection(currentSelection)) {
currentSelection.insertNodes(
nextAttachments.map((attachment) =>
$createAttachmentNode(attachment),
),
);
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
[editor],
);
useEffect(
() =>
editor.registerCommand(
INSERT_CLIPBOARD_TEXT_COMMAND,
(text) => {
selectEditableEndWhenNeeded();
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(text);
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
[editor],
);
useEffect(
() =>
// this ONLY works in Tauri, no plan for browser by now.
editor.registerCommand(
PASTE_COMMAND,
(event) => {
event.preventDefault();
void readNativeClipboardImageFile().then(async (file) => {
if (!file) {
const text = await readNativeClipboardText();
if (text.length === 0) {
return;
}
editor.dispatchCommand(INSERT_CLIPBOARD_TEXT_COMMAND, text);
return;
}
const nextAttachments = createAttachmentDrafts([file]);
editor.dispatchCommand(INSERT_ATTACHMENTS_COMMAND, nextAttachments);
});
return true;
},
COMMAND_PRIORITY_HIGH,
),
[editor],
);
return (
<OnChangePlugin
onChange={(editorState) => {
onChange(editorState);
}}
/>
);
}
export function UploadButton() {
const [editor] = useLexicalComposerContext();
const inputRef = useRef<HTMLInputElement | null>(null);
return (
<>
<input
ref={inputRef}
className="launcher-file-input pointer-events-none absolute size-px opacity-0"
type="file"
multiple
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length === 0) return;
const nextAttachments = createAttachmentDrafts(files);
editor.dispatchCommand(INSERT_ATTACHMENTS_COMMAND, nextAttachments);
}}
/>
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-(--platform-icon-text)"
type="button"
aria-label="上传素材"
onClick={() => inputRef.current?.click()}
>
<Upload size={15} aria-hidden="true" />
</button>
</>
);
}
export default function RichInputArea(props: RichInputAreaProps) {
return (
<LexicalComposer
initialConfig={{
namespace: 'home-rich-input',
nodes: [AttachmentNode],
editorState: props.value ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<div className="grid min-h-9 gap-2">
<RichTextPlugin
contentEditable={
<ContentEditable
aria-label="创作想法"
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
/>
}
placeholder={
<span className="pointer-events-none absolute text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/>
{props.children}
</div>
<EditorPlugins onChange={props.onChange} />
</LexicalComposer>
);
}
@@ -0,0 +1,86 @@
import {
$getRoot,
$isElementNode,
$isLineBreakNode,
$isTextNode,
type LexicalNode,
} from 'lexical';
import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore';
import { $isAttachmentNode } from './attachmentNode';
function ordinalSuffix(index: number) {
if (index % 100 >= 11 && index % 100 <= 13) return 'th';
return (
({ 1: 'st', 2: 'nd', 3: 'rd' } as Record<number, string>)[index % 10] ??
'th'
);
}
function collectPromptParts(
node: LexicalNode,
output: string[],
attachmentIndex: { value: number },
) {
if ($isTextNode(node)) {
output.push(node.getTextContent());
return;
}
if ($isLineBreakNode(node)) {
output.push('\n');
return;
}
if ($isAttachmentNode(node)) {
attachmentIndex.value += 1;
output.push(
`{${attachmentIndex.value}${ordinalSuffix(attachmentIndex.value)} attachment: ${node.__attachment.file.name}}`,
);
return;
}
if ($isElementNode(node)) {
const children = node.getChildren();
children.forEach((child, index) => {
if (index > 0 && node.getType() === 'root') {
output.push('\n');
}
collectPromptParts(child, output, attachmentIndex);
});
}
}
export function richTextToPrompt(richText: Draft) {
if (!richText) return '';
return richText.read(() => {
const output: string[] = [];
collectPromptParts($getRoot(), output, { value: 0 });
return output.join('').trim();
});
}
function collectAttachments(
node: LexicalNode,
attachments: HomeAttachmentDraft[],
seenAttachmentIds: Set<string>,
) {
if ($isAttachmentNode(node)) {
if (!seenAttachmentIds.has(node.__attachment.id)) {
seenAttachmentIds.add(node.__attachment.id);
attachments.push(node.__attachment);
}
return;
}
if ($isElementNode(node)) {
for (const child of node.getChildren()) {
collectAttachments(child, attachments, seenAttachmentIds);
}
}
}
export function richTextToAttachments(richText: Draft) {
if (!richText) return [];
return richText.read(() => {
const attachments: HomeAttachmentDraft[] = [];
collectAttachments($getRoot(), attachments, new Set());
return attachments;
});
}
@@ -3,27 +3,30 @@ import {
type LucideIcon,
Plus,
Sparkles,
Upload,
X,
} from 'lucide-react';
import type {
FormEvent,
} from 'react';
import { useRef, useState } from 'react';
import { useState } from 'react';
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'
import RichInputArea, { UploadButton } from './components/RichInputArea';
import {
richTextToAttachments,
richTextToPrompt,
} from './components/RichInputArea/richTextToPrompt';
import {
useLauncherHomeDraftStore,
type HomeAgentMode,
type HomeAttachmentDraft,
type HomeDraft,
} from '../../stores/useHomeDraftStore';
useLauncherHomeDraftStore,
} from './useHomeDraftStore';
import { useHomeShowcase } from './useHomeShowcase';
export type {
HomeAgentMode,
HomeAttachmentDraft,
HomeDraft,
} from '../../stores/useHomeDraftStore';
} from './useHomeDraftStore';
export type HomeAgentModeItem = {
mode: HomeAgentMode;
@@ -39,17 +42,6 @@ export type HomeProjectRow = {
status: string;
canOpen: boolean;
};
export type HomeShowcaseResource = {
resourceId: string;
showcaseId?: string | null;
label?: string | null;
imageSrc: string;
prompt?: string | null;
authorDisplayName?: string | null;
authorPublicUserCode?: string | null;
};
type HomeViewProps = {
hasPromo: boolean;
status: string;
@@ -72,22 +64,14 @@ export default function HomeView({
onProjectOpen,
}: HomeViewProps) {
const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode);
const homePrompt = useLauncherHomeDraftStore((state) => state.prompt);
const homeAttachments = useLauncherHomeDraftStore(
(state) => state.attachments,
);
const homeRichText = useLauncherHomeDraftStore((state) => state.draft);
const setHomeAgentMode = useLauncherHomeDraftStore((state) => state.setMode);
const setHomePrompt = useLauncherHomeDraftStore((state) => state.setPrompt);
const addHomeAttachments = useLauncherHomeDraftStore(
(state) => state.addAttachments,
);
const removeHomeAttachment = useLauncherHomeDraftStore(
(state) => state.removeAttachment,
const setHomeRichText = useLauncherHomeDraftStore(
(state) => state.setRichText,
);
const [homeCreationBusy, setHomeCreationBusy] = useState(false);
const { resources: showcaseResources, status: showcaseStatus } =
useHomeShowcase();
const homeAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const activeHomeMode =
homeAgentModeItems.find((item) => item.mode === homeAgentMode) ??
homeAgentModeItems[0];
@@ -96,27 +80,11 @@ export default function HomeView({
return null;
}
const ActiveHomeModeIcon = activeHomeMode.icon;
function handleHomeAttachmentSelection(
event: React.ChangeEvent<HTMLInputElement>,
) {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length === 0) {
return;
}
addHomeAttachments(
files.map((file, index) => ({
id: `${file.name}:${file.size}:${file.lastModified}:${index}:${Date.now()}`,
file,
})),
);
}
async function handleHomeSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!homePrompt.trim() && homeAttachments.length === 0) {
const referencedAttachments = richTextToAttachments(homeRichText);
const prompt = richTextToPrompt(homeRichText);
if (!prompt) {
onStatusChange(
homeAgentModeItems.find((item) => item.mode === homeAgentMode)
?.emptyPrompt ?? '请输入需求或上传附件',
@@ -129,8 +97,8 @@ export default function HomeView({
onStatusChange(
await onCreateDraft({
mode: homeAgentMode,
prompt: homePrompt,
attachments: homeAttachments,
prompt,
attachments: referencedAttachments,
}),
);
} catch (error) {
@@ -141,28 +109,28 @@ export default function HomeView({
}
return (
<>
<div className="platform-theme platform-theme--light min-h-screen bg-(image:--platform-body-fill) text-(--platform-text-strong)">
<section
className={`grid min-h-93.5 content-start justify-items-center gap-3.25 px-6 pb-4.5 pt-25.75 max-[760px]:min-h-0 max-[760px]:gap-4 max-[760px]:px-3.5 max-[760px]:pb-[34px] max-[760px]:pt-[84px] ${
className={`grid min-h-93.5 content-start justify-items-center gap-3.25 px-6 pb-4.5 pt-25.75 max-[760px]:min-h-0 max-[760px]:gap-4 max-[760px]:px-3.5 max-[760px]:pb-8.5 max-[760px]:pt-21 ${
hasPromo ? 'pt-33.75 max-[760px]:pt-21' : ''
}`}
aria-label="首页输入"
>
<div className="flex items-center gap-3 text-center max-[760px]:flex-col max-[760px]:gap-2.5">
<span className="grid size-7.5 shrink-0 place-items-center rounded-full bg-[#060606] text-[11px] font-bold text-white">
<ActiveHomeModeIcon size={18} aria-hidden="true" />
<span className="grid size-32 shrink-0 place-items-center rounded-full shadow-(--platform-profile-action-shadow)">
<img src={BRAND_ICON} alt="logo" className="size-32" />
</span>
<div>
<h1 className="m-0 text-[27px] font-[750] tracking-normal max-[760px]:text-[22px]">
<h1 className="m-0 text-[27px] font-[750] tracking-normal text-(--platform-brand-logo-title) max-[760px]:text-[22px]">
GameAgent
</h1>
<p className="m-0 mt-3 text-[13px] text-[#a0a0a0]">
<p className="m-0 mt-3 text-[13px] text-(--platform-text-soft)">
{activeHomeMode.placeholder}
</p>
</div>
</div>
<div
className="flex w-[min(488px,calc(100vw-110px))] flex-wrap justify-center gap-[9px] max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="flex w-[min(488px,calc(100vw-110px))] flex-wrap justify-center gap-2.25 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="Agent 分类"
>
{homeAgentModeItems.map((item) => {
@@ -174,8 +142,8 @@ export default function HomeView({
key={item.mode}
className={`inline-flex min-h-6.75 items-center gap-1.25 rounded-full border px-3 text-[12px] ${
isActive
? 'border-violet-500 bg-[#f8f2ff] text-violet-600'
: 'border-[#d6d6d6] bg-white text-[#333]'
? 'border-(--platform-nav-active-border) bg-(image:--platform-nav-active-fill) text-(--platform-warm-text) shadow-(--platform-nav-active-shadow)'
: 'border-(--platform-subpanel-border) bg-(image:--platform-subpanel-fill) text-(--platform-neutral-text)'
}`}
onClick={() => setHomeAgentMode(item.mode)}
>
@@ -185,80 +153,49 @@ export default function HomeView({
);
})}
</div>
<span className="text-[12px] text-(--platform-text-soft)">
{status}
</span>
<form
className="grid min-h-20 w-[min(488px,calc(100vw-110px))] grid-rows-[minmax(42px,auto)_auto_auto] rounded-[18px] border border-[#e1e4e8] bg-white px-3 pb-2 pt-[13px] shadow-[0_8px_24px_rgb(15_23_42_/_8%)] max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="grid min-h-20 w-[min(1054px,calc(100vw-122px))] grid-rows-[minmax(42px,auto)_auto] rounded-[18px] border border-(--platform-surface-border) bg-(--platform-input-fill) px-3 pb-2 pt-3.25 shadow-(--platform-panel-shadow) focus-within:bg-(--platform-input-fill-focus) focus-within:ring-4 focus-within:ring-(--platform-input-focus-ring) max-[760px]:w-[min(100%,calc(100vw-76px))]"
onSubmit={handleHomeSubmit}
>
<textarea
className="min-h-9 w-full resize-none border-0 bg-transparent p-0 text-[13px] text-[#171717] outline-0 placeholder:text-[#bababa]"
aria-label="创作想法"
<RichInputArea
value={homeRichText}
placeholder={activeHomeMode.placeholder}
value={homePrompt}
onChange={(event) => setHomePrompt(event.currentTarget.value)}
/>
{homeAttachments.length > 0 ? (
<div className="flex flex-wrap gap-1.5 py-2 pb-1" aria-label="附件队列">
{homeAttachments.map((attachment) => (
<span
className="inline-flex min-h-6 max-w-full items-center gap-1 rounded-[7px] border border-gray-200 bg-slate-50 px-[7px] text-[11px] text-gray-700"
key={attachment.id}
>
{attachment.file.name}
<button
className="size-[18px] cursor-pointer border-0 bg-transparent p-0 text-gray-500"
type="button"
aria-label={`移除附件 ${attachment.file.name}`}
onClick={() => removeHomeAttachment(attachment.id)}
>
<X size={12} aria-hidden="true" />
</button>
</span>
))}
onChange={setHomeRichText}
>
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
<UploadButton />
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) disabled:cursor-not-allowed disabled:opacity-55"
type="submit"
aria-label="开启创作"
disabled={homeCreationBusy}
>
<Plus size={16} aria-hidden="true" />
</button>
</div>
) : null}
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-2.5 text-[12px] text-[#777]">
<input
ref={homeAttachmentInputRef}
className="launcher-file-input pointer-events-none absolute size-px opacity-0"
type="file"
multiple
onChange={handleHomeAttachmentSelection}
/>
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-[#666]"
type="button"
aria-label="上传素材"
onClick={() => homeAttachmentInputRef.current?.click()}
>
<Upload size={15} aria-hidden="true" />
</button>
<span>{status}</span>
<button
className="grid size-6 cursor-pointer place-items-center rounded-full border-0 bg-[#f1f1f1] p-0 text-[#9a9a9a] disabled:cursor-not-allowed disabled:opacity-55"
type="submit"
aria-label="开启创作"
disabled={homeCreationBusy}
>
<Plus size={16} aria-hidden="true" />
</button>
</div>
</RichInputArea>
</form>
</section>
<section
className="mx-auto grid w-[min(1054px,calc(100vw-122px))] gap-[14px] max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="mx-auto grid w-[min(1054px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="最近项目"
>
<header className="flex items-center justify-between gap-3">
<div>
<h2 className="m-0 text-[15px] text-gray-900"></h2>
<p className="mb-0 mt-1 text-[12px] text-[#8b8b8b]">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
</h2>
<p className="mb-0 mt-1 text-[12px] text-(--platform-text-soft)">
{status}
</p>
</div>
<div className="launcher-project-list-actions">
<button
className="cursor-pointer border-0 bg-transparent p-0 text-[12px] text-[#888]"
className="cursor-pointer border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
type="button"
onClick={onProjectsOpen}
>
@@ -271,21 +208,21 @@ export default function HomeView({
{recentProjectRows.map((project) => (
<article className="grid min-w-0" key={project.path}>
<button
className="grid h-auto w-full cursor-pointer content-start justify-items-start gap-1.5 overflow-hidden rounded-lg border border-[#e1e4e8] bg-[#f7f7f7] p-3 text-left disabled:cursor-not-allowed disabled:opacity-[0.58] max-[760px]:h-[132px]"
className="grid h-auto w-full cursor-pointer content-start justify-items-start gap-1.5 overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-3 text-left shadow-(--platform-panel-shadow) transition hover:border-(--platform-surface-hover-border) disabled:cursor-not-allowed disabled:opacity-[0.58] max-[760px]:h-33"
type="button"
disabled={!project.canOpen}
onClick={() => onProjectOpen(project.path)}
>
<span className="grid size-[42px] place-items-center rounded-xl bg-white text-[16px] font-bold text-[#f26d2d]">
<span className="grid size-10.5 place-items-center rounded-xl bg-(--platform-warm-bg) text-[16px] font-bold text-(--platform-warm-text)">
{project.name.slice(0, 2)}
</span>
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-medium text-[#111]">
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-medium text-(--platform-text-strong)">
{project.name}
</strong>
<small className="-mt-[5px] min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-[#8b8b8b]">
<small className="-mt-1.25 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-(--platform-text-soft)">
{project.path}
</small>
<small className="-mt-[5px] min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-[#8b8b8b]">
<small className="-mt-1.25 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-(--platform-text-soft)">
{project.status}
</small>
</button>
@@ -293,11 +230,17 @@ export default function HomeView({
))}
</div>
) : (
<div className="grid justify-items-center gap-2.5 px-0 pb-8 pt-[18px]">
<FolderKanban size={22} aria-hidden="true" />
<strong></strong>
<div className="grid justify-items-center gap-2.5 px-0 pb-8 pt-4.5">
<FolderKanban
className="text-(--platform-icon-text)"
size={22}
aria-hidden="true"
/>
<strong className="text-[13px] text-(--platform-text-strong)">
</strong>
<button
className="cursor-pointer border-0 bg-transparent p-0 text-[12px] text-[#888]"
className="cursor-pointer border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
type="button"
onClick={onProjectsOpen}
>
@@ -308,34 +251,42 @@ export default function HomeView({
</section>
<section
className="mx-auto mt-[22px] grid w-[min(1054px,calc(100vw-122px))] gap-[14px] max-[760px]:w-[min(100%,calc(100vw-76px))]"
className="mx-auto mt-5.5 grid w-[min(1054px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="灵感推荐"
>
<header className="flex items-center justify-start gap-3">
<div className="grid gap-1">
<span className="inline-flex items-center gap-1.5">
<h2 className="m-0 text-[15px] text-gray-900"></h2>
<Sparkles size={15} aria-hidden="true" />
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
</h2>
<Sparkles
className="text-(--platform-warm-text)"
size={15}
aria-hidden="true"
/>
</span>
<p className="mb-0 mt-1 text-[12px] text-[#8b8b8b]">{showcaseStatus}</p>
<p className="mb-0 mt-1 text-[12px] text-(--platform-text-soft)">
{showcaseStatus}
</p>
</div>
</header>
{showcaseResources.length > 0 ? (
<div className="grid grid-cols-6 gap-3 max-[760px]:grid-cols-1">
{showcaseResources.map((resource) => (
<article
className="grid gap-[7px] overflow-hidden rounded-lg border border-gray-200 bg-white pb-2.5"
className="grid gap-1.75 overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) pb-2.5 shadow-(--platform-panel-shadow)"
key={resource.showcaseId ?? resource.resourceId}
>
<img
className="aspect-square w-full object-cover bg-gray-100"
className="aspect-square w-full object-cover bg-(--platform-warm-bg)"
src={resource.imageSrc}
alt={resource.label || resource.prompt || '灵感素材'}
/>
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap px-[9px] text-[12px] text-gray-900">
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap px-2.25 text-[12px] text-(--platform-text-strong)">
{resource.label || '未命名素材'}
</strong>
<small className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap px-[9px] text-[11px] text-gray-500">
<small className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap px-2.25 text-[11px] text-(--platform-text-soft)">
{resource.authorDisplayName ||
resource.authorPublicUserCode ||
'陶泥儿精选'}
@@ -344,11 +295,11 @@ export default function HomeView({
))}
</div>
) : (
<div className="grid min-h-24 place-items-center overflow-hidden rounded-lg border border-gray-200 bg-white text-[11px] text-gray-500">
<div className="grid min-h-24 place-items-center overflow-hidden rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) text-[11px] text-(--platform-text-soft)">
{showcaseStatus}
</div>
)}
</section>
</>
</div>
);
}
@@ -0,0 +1,41 @@
import type { EditorState } from 'lexical';
import { create } from 'zustand';
export type HomeAttachmentDraft = {
id: string;
file: File;
};
export type Draft = EditorState | null;
export type HomeAgentMode = 'game' | 'art' | 'doc';
export type HomeDraft = {
mode: HomeAgentMode;
prompt: string;
attachments: HomeAttachmentDraft[];
};
type UseHomeDraftStore = {
mode: HomeAgentMode;
draft: Draft;
setMode: (mode: HomeAgentMode) => void;
setRichText: (richText: Draft) => void;
reset: () => void;
};
const initialHomeDraft: Pick<
UseHomeDraftStore,
'mode' | 'draft'
> = {
mode: 'game',
draft: null,
};
// Keep the immutable Lexical snapshot available while the Home view is unmounted.
export const useLauncherHomeDraftStore = create<UseHomeDraftStore>((set) => ({
...initialHomeDraft,
setMode: (mode) => set({ mode }),
setRichText: (draft) => set({ draft: draft }),
reset: () => set(initialHomeDraft),
}));
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import {
listClientShowcaseResources,
type EditorShowcaseResource,
listClientShowcaseResources,
} from '../../services/clientApi';
export function useHomeShowcase() {
@@ -0,0 +1,341 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import {
CircleHelp,
FolderKanban,
Home,
Plus,
Settings,
User,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import BRAND_ICON from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { useWalletStore } from '../stores/useWalletStore';
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
const sidebarIconButtonClass =
'grid h-12 w-12 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-[15px] text-(--platform-nav-item-text) hover:bg-(--platform-nav-item-hover-fill)';
const sidebarHelpButtonClass =
'grid h-12 w-12 cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 text-[15px] text-(--platform-nav-item-text)';
const sidebarMenuItemClass =
'flex h-[30px] w-full cursor-pointer items-center justify-start gap-2 whitespace-nowrap rounded-[7px] border-0 bg-transparent px-[9px] py-0 text-left text-xs text-(--platform-text-strong) hover:bg-(--platform-nav-item-hover-fill) focus-visible:bg-(--platform-nav-item-hover-fill)';
export type LauncherView =
| 'home'
| 'projects'
| 'agent-chat'
| 'guide'
| 'contact'
| 'news'
| 'project-development';
type SidebarUserInfo = {
displayName?: string | null;
phoneNumberMasked?: string | null;
publicUserCode?: string | null;
};
type LauncherSidebarProps = {
activeView: LauncherView;
currentUser: SidebarUserInfo;
onViewChange: (view: LauncherView) => void;
onNoticeRequest: (title: string) => void;
onRechargeRequest: () => void;
onRuntimeConfigOpen: () => void;
onLogout: () => void;
};
type SidebarAccountMenuProps = {
currentUser: SidebarUserInfo;
onClose: () => void;
onLogout: () => void;
onNoticeRequest: (title: string) => void;
onRechargeRequest: () => void;
};
function formatMudPoints(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '--';
}
return Math.max(0, Math.floor(value)).toLocaleString('zh-CN');
}
function SidebarAccountMenu({
currentUser,
onClose,
onLogout,
onNoticeRequest,
onRechargeRequest,
}: SidebarAccountMenuProps) {
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();
onNoticeRequest(title);
}
return (
<div
className="absolute -bottom-1 left-14 z-90 grid w-54.5 gap-1.5 rounded-lg border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] p-2.5 text-(--platform-text-strong)"
role="menu"
>
<strong className="text-[13px]">
{currentUser.displayName || '陶泥儿用户'}
</strong>
<span className="text-[11px] text-(--platform-text-soft)">
{currentUser.phoneNumberMasked ||
currentUser.publicUserCode ||
'账号信息读取中'}
</span>
<div
className="grid gap-2 rounded-xl bg-[linear-gradient(135deg,#eaa06a,#cf7a4a_58%,#b55c3b)] p-3 text-white shadow-[0_18px_38px_rgb(189_103_60_/_22%)]"
role="group"
aria-label="会员方案"
>
{/*SHOULD NOT SHOW IN THIS VERSION*/}
{/*<span className="grid size-8 shrink-0 place-items-center rounded-[0.85rem] bg-white/25">*/}
{/* <Crown size={16} aria-hidden="true" />*/}
{/*</span>*/}
{/*<span className="min-w-0">*/}
{/* <span className="block truncate text-sm font-black">*/}
{/* /!*{membershipLabel}*!/*/}
{/* </span>*/}
{/* <span className="block truncate text-[11px] font-semibold text-white/85">*/}
{/* 会员方案*/}
{/* </span>*/}
{/*</span>*/}
<div className="flex items-center justify-between gap-2">
<span
className="min-w-0 text-[12px] font-bold"
title={walletBalanceStatus}
>
{walletBalanceLabel}
</span>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-full border border-white/70 bg-white/15 text-white"
aria-label="充值泥点"
onClick={() => {
onClose();
onRechargeRequest();
}}
>
<Plus size={14} aria-hidden="true" />
</button>
{/*<button*/}
{/* type="button"*/}
{/* className="h-8 rounded-full border border-white/85 bg-white/10 px-3 text-[12px] font-black text-white"*/}
{/* onClick={() => {*/}
{/* closeMenus();*/}
{/* // TODO upgrade component and logic is outdated on this branch*/}
{/* }}*/}
{/*>*/}
{/* 升级*/}
{/*</button>*/}
</div>
</div>
<button
type="button"
role="menuitem"
className={sidebarMenuItemClass}
onClick={
() =>{
onClose();
showNotice('使用指南');
}
// TODO doc is not ready
}
>
使
</button>
<button
type="button"
role="menuitem"
className={sidebarMenuItemClass}
onClick={
() => openUrl("https://kcnz41bksl1c.feishu.cn/share/base/form/shrcn1p9WpuZMDxrI5ysLLJhw7k")
}
>
</button>
<button
type="button"
role="menuitem"
className={sidebarMenuItemClass}
onClick={() => {
onClose();
onLogout();
}}
>
退
</button>
</div>
);
}
export function Sidebar({
activeView,
currentUser,
onViewChange,
onNoticeRequest,
onRechargeRequest,
onRuntimeConfigOpen,
onLogout,
}: LauncherSidebarProps) {
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
const helpMenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function closeSidebarMenusIfOutside(target: EventTarget | null) {
if (!(target instanceof Node)) {
return;
}
if (
accountMenuRef.current?.contains(target) ||
helpMenuRef.current?.contains(target)
) {
return;
}
setAccountMenuOpen(false);
}
function handlePointerDown(event: PointerEvent) {
closeSidebarMenusIfOutside(event.target);
}
function handleFocusIn(event: FocusEvent) {
closeSidebarMenusIfOutside(event.target);
}
function handleWindowBlur() {
setAccountMenuOpen(false);
}
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('focusin', handleFocusIn);
window.addEventListener('blur', handleWindowBlur);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('focusin', handleFocusIn);
window.removeEventListener('blur', handleWindowBlur);
};
}, []);
function closeMenus() {
setAccountMenuOpen(false);
}
function changeView(view: LauncherView) {
closeMenus();
onViewChange(view);
}
return (
<aside className="sticky top-0 z-40 flex h-screen w-full flex-col items-center gap-4 overflow-visible border-r border-(--platform-surface-border) bg-(--platform-nav-fill) px-0 pb-4.5 pt-4">
<div className="grid place-items-center" aria-label="陶泥儿">
<span className="grid size-12 place-items-center rounded-full bg-(--platform-button-primary-fill) text-[10px] font-bold tracking-normal text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow)">
<img src={BRAND_ICON} alt="brand logo" />
</span>
</div>
<nav
className="grid justify-items-center gap-3.25"
aria-label="客户端导航"
>
<button
type="button"
aria-label="首页"
title="首页"
className={cx(
sidebarIconButtonClass,
activeView === 'home' &&
'border border-(--platform-nav-active-border) bg-(image:--platform-nav-active-fill) text-(--platform-nav-item-text-active) shadow-(--platform-nav-active-shadow)',
)}
onClick={() => changeView('home')}
>
<Home size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="项目组"
title="项目组"
className={cx(
sidebarIconButtonClass,
activeView === 'projects' &&
'border border-(--platform-nav-active-border) bg-(image:--platform-nav-active-fill) text-(--platform-nav-item-text-active) shadow-(--platform-nav-active-shadow)',
)}
onClick={() => changeView('projects')}
>
<FolderKanban size={17} aria-hidden="true" />
</button>
</nav>
<div className="relative z-[60] mt-auto grid justify-items-center gap-[13px]">
<div className="relative" ref={accountMenuRef}>
<button
type="button"
className={sidebarIconButtonClass}
onClick={() => {
setAccountMenuOpen((open) => !open);
}}
aria-expanded={accountMenuOpen}
aria-label="我的"
title="我的"
>
<User size={16} aria-hidden="true" />
</button>
{accountMenuOpen ? (
<SidebarAccountMenu
currentUser={currentUser}
onClose={closeMenus}
onLogout={onLogout}
onNoticeRequest={onNoticeRequest}
onRechargeRequest={onRechargeRequest}
/>
) : null}
</div>
<div className="relative" ref={helpMenuRef}>
<button
type="button"
aria-label="帮助"
title="帮助"
className={sidebarHelpButtonClass}
onClick={() => {
closeMenus();
onNoticeRequest('使用指南');
// TODO open the guide
// TODO doc is not ready
}}
>
<CircleHelp size={17} aria-hidden="true" />
</button>
</div>
<button
type="button"
className={sidebarIconButtonClass}
onClick={() => {
closeMenus();
onRuntimeConfigOpen();
}}
aria-label="配置"
title="配置"
>
<Settings size={16} aria-hidden="true" />
</button>
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -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');
});
});
@@ -273,6 +273,7 @@
- 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。
- 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6``180 + 90 / ¥18``300 + 150 / ¥30``680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。
- 2026-07-17 追加:主站、图片画板与 AI 游戏创作独立 App 的泥点账单统一复用 `packages/shared/src/components/PlatformProfileWalletLedgerModal`。共享组件只依赖 `ProfileWalletLedgerResponse`,承接来源 label、金额正负号、UTC 日期、余额兜底和 loading / empty / error 展示;`/api/profile/wallet-ledger` 请求、鉴权、打开状态与重试生命周期继续由各宿主持有,不把账户事实或后端副作用下沉到共享 UI。
- 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。
- 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -4994,6 +4995,13 @@
- 运维口径:`GENARRATIVE_SPACETIME_POOL_SIZE=8` 表示 8 条调用连接,HTTP 基础拓扑另加 1 条缓存读连接;外部生成和充值过期监听的独立窄订阅不计入该值。读模型行缓存从 8 份降为 1 份,但 SDK 空 table metadata、8 条调用 socket 和 runner 仍存在,不承诺总 RSS 等比例降为八分之一。
- 验证方式:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`;发布后在 8 个调用槽暖机后对比 api-server cgroup memory / PSS,并确认 `/readyz` 与代表性 gallery、公开详情、创作入口和用户标签读取正常。
## 2026-07-17 主站与 AI 游戏创作客户端复用微信 Native 充值生命周期
- 背景:主站个人中心已具备微信 Native 二维码确认重试和 SSE 自动到账监听,AI 游戏创作客户端又在大型 `App.tsx` 中维护一套简化充值状态,关闭、迟到响应和终态语义容易继续分叉。
- 决策:`packages/shared` 的充值组件目录新增宿主无关的 `useWechatNativeRechargeController`,通过注入确认、监听和余额快照回调统一管理二维码校验、手动确认重试、SSE 监听、终态映射和 lifecycle 隔离。主站只把 Native 分支委托给共享 controller,H5、JSAPI、小程序、登录恢复、任务和邀请码仍留在原 controller;AI 游戏创作客户端通过本地 `useRechargeController` 托管弹窗加载与固定 `wechat_native` 下单,`App.tsx` 只负责视图接线。
- 余额边界:AI 游戏创作客户端的 `useWalletStore.mudPointBalance` 仍是唯一余额真相;充值响应只把后端完整快照写入 store,支付成功后触发完整刷新,不在客户端本地推算或增减泥点。
- 验证方式:共享 hook Vitest、AI 游戏创作客户端充值与 Wallet Store 定向测试、主站充值渠道定向测试、两个 TypeScript 边界、`npm run check:encoding``git diff --check`
## 2026-07-17 Project Supervisor 协作合同由 Runtime 强制执行
- 背景:V1.31 已真实证明同一父 run 可以组合 static delegate 与 isolated all-join,但模型仍可能漏掉某一类协作、只提交一个 static delegate,或在委派后由 Supervisor 自己执行项目修改。重复采样和继续堆 prompt 不能作为可靠性门禁。
@@ -45,6 +45,8 @@ hermes
npm install
```
仓库当前不使用 npm workspaces,根目录 `npm install` 是统一安装入口。子包新增运行时依赖时,必须同步写入根 `package.json` 和根 `package-lock.json`;不能只修改子包 `package.json`
完整联调开发环境:
```bash
@@ -101,6 +101,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 进程。
@@ -359,7 +361,7 @@ game-project/
- 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期 / 黑板记忆,角色私有记忆只在单 agent 对话和生成 loop 中按目标 agent 读取。
- 结构化对话记录按授权本地项目路径追加 JSONL;正式聊天读取 Supervisor active Session 与只读 legacy 项目历史,开发单 Agent 对话读取对应 Agent Session。开发入口已支持本地 Session 新建、切换、归档和分叉,但不提供云端同步。
- Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单、`.agent/run.latest.json` / `.agent/runs/<runId>.json` 的 step、taskGraph、passPlans、lifecycleStatus,以及 `read_game_creator_agent_runtimes` 批量读取的 `.agent/runtime/agents/<taskId>.json` 和最近任务派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。
- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件,再把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,写入最近项目并切到项目开发页。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft``generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。
- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件,再把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,写入最近项目并切到项目开发页。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft``generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。
- 首页发送、项目组目录选择和本地文件选择必须使用 Tauri 非阻塞原生 picker,并把选择器绑定到当前 `client` 窗口;禁止在同步 command 中调用 `blocking_pick_folder` / `blocking_pick_file` 阻塞 WebView 事件循环。选择器打开期间保留首页草稿可编辑,取消后恢复“开启创作”按钮并回显“已取消”。
- debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、管理对应 active/archived Session 并读取历史,用户消息和真实 Agent 回复只持久化到 `.agent/conversations/agents/<agentId>/` 下的规范 Session。普通用户窗口不得出现 `Agent 聊天` 导航、picker 或工具台入口。
- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口管理最近项目、打开项目、新建项目和显示目录。打开项目只读取已初始化项目并切到项目开发页,不打开第二窗口;新建项目仍沿用非空目录确认,不自动重建无效历史路径。
@@ -567,6 +569,7 @@ game-project/
- 聊天输入 `/publish` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要,在聊天里生成发布准备清单,列出原型通过状态、预览、任务、资产、音频、包装说明和试玩包导出状态,并提供 `/run``/trace``/agent-resume ``/export` 草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件、不新增普通用户面板,真正导出仍由用户发送 `/export` 并走确认流。
- 开发窗口可从 Agent 状态列表进入单个专业 Agent 对话并管理其 Session;正式用户项目开发页只读展示底部专业 Agent 状态和悬浮详情,不提供单 Agent 对话入口、Session 控件或调度工具台。
- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页管理最近项目、打开项目、新建项目和显示目录;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。
- 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。“使用详情”统一打开 `packages/shared` 中的 `PlatformProfileWalletLedgerModal`,由各宿主分别维护打开、加载、失败重试状态并读取 `/api/profile/wallet-ledger`,共享组件只承接账单来源文案、金额与日期展示及 loading / empty / error 视图,不发请求、不持有账户事实。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。
- 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。
- 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。
- 首页发送和项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。
+1039
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -158,7 +158,11 @@
},
"dependencies": {
"@expo/metro-runtime": "^56.0.15",
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"cannon-es": "^0.20.0",
"dotenv": "^17.2.3",
@@ -175,6 +179,7 @@
"expo-sharing": "^56.0.18",
"expo-status-bar": "^56.0.4",
"jszip": "^3.10.1",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"qrcode": "^1.5.4",

Some files were not shown because too many files have changed in this diff Show More