diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts
index 637ecc213..c3a9e7256 100644
--- a/apps/admin-web/src/api/adminApiTypes.ts
+++ b/apps/admin-web/src/api/adminApiTypes.ts
@@ -1075,7 +1075,7 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
-export type AdminAgcAgentMode = 'codex' | 'cc';
+export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic';
export interface AdminAgcModel {
id: string;
diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
index c64efc67d..cab805cf7 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
@@ -226,6 +226,7 @@ export function AdminAgcModelsPage({
>
+
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index 1b964573f..a7dda892c 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -140,6 +140,7 @@ const allowedUncalledTauriCommands = [
// 里以字面量出现,静态扫描仍按共享注册表核验。
'read_profile_recharge_center',
'read_profile_wallet_ledger',
+ 'read_profile_referral_invite_center',
'create_profile_recharge_order',
'confirm_wechat_profile_recharge_order',
'redeem_profile_reward_code',
diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs
index 3f882e163..2cdc1b40d 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs
@@ -8,10 +8,12 @@ use crate::http_client::agc_main_site_client_builder;
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
+use serde::Serialize;
use serde_json::Value;
use shared_contracts::runtime::{
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse,
- ProfileRechargeCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
+ ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse,
+ ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
};
use std::time::Duration;
use url::Url;
@@ -103,6 +105,29 @@ fn unwrap_envelope(body: &str, fallback: &str) -> Result {
Ok(value.get("data").cloned().unwrap_or(value))
}
+/// 客户端邀请码弹层需要的完整数据。
+///
+/// 后端只返回站内相对路径(`inviteLinkPath`),客户端要拿它当分享链接用,
+/// 所以这里用当前登录站点 origin 补全成完整 URL 再交给渲染层。
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct ClientProfileReferralInviteCenter {
+ pub(crate) center: ProfileReferralInviteCenterResponse,
+ pub(crate) invite_url: String,
+}
+
+fn resolve_invite_url(api_base_url: &str, invite_link_path: &str) -> String {
+ if invite_link_path.trim().is_empty() {
+ return api_base_url.trim_end_matches('/').to_string();
+ }
+ match Url::parse(&format!("{}/", api_base_url.trim_end_matches('/')))
+ .and_then(|base| base.join(invite_link_path))
+ {
+ Ok(url) => url.to_string(),
+ Err(_) => invite_link_path.to_string(),
+ }
+}
+
async fn request_json(
client: &reqwest::Client,
snapshot: &PlatformSessionSnapshot,
@@ -171,6 +196,24 @@ pub(crate) async fn read_profile_wallet_ledger() -> Result Result {
+ let snapshot = require_session()?;
+ let client = build_client()?;
+ let center: ProfileReferralInviteCenterResponse = request_json(
+ &client,
+ &snapshot,
+ Method::GET,
+ &["api", "profile", "referrals", "invite-center"],
+ None,
+ "读取邀请码失败",
+ )
+ .await?;
+ let invite_url = resolve_invite_url(&snapshot.api_base_url, ¢er.invite_link_path);
+ Ok(ClientProfileReferralInviteCenter { center, invite_url })
+}
+
#[tauri::command]
pub(crate) async fn create_profile_recharge_order(
product_id: String,
@@ -314,4 +357,20 @@ mod tests {
assert!(bounded_business_id(&"x".repeat(MAX_BUSINESS_ID_CHARS + 1), "订单").is_err());
assert!(bounded_business_id("bad\nid", "订单").is_err());
}
+
+ #[test]
+ fn invite_link_resolves_against_login_origin() {
+ assert_eq!(
+ resolve_invite_url("https://dev.genarrative.world", "/?inviteCode=SY12345678"),
+ "https://dev.genarrative.world/?inviteCode=SY12345678"
+ );
+ assert_eq!(
+ resolve_invite_url("https://dev.genarrative.world/", "invite/SY12345678"),
+ "https://dev.genarrative.world/invite/SY12345678"
+ );
+ assert_eq!(
+ resolve_invite_url("https://dev.genarrative.world", ""),
+ "https://dev.genarrative.world"
+ );
+ }
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
index 8adffce7d..263b314da 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
@@ -352,7 +352,21 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) {
fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option {
std::env::var("ANTHROPIC_BASE_URL")
.ok()
- .or_else(|| llm.map(|value| value.base_url.trim_end_matches('/').to_string()))
+ .or_else(|| {
+ let llm = llm?;
+ // Official models never talk to the Router directly: the account
+ // Router credential lives server-side, so the Claude sidecar goes
+ // through the platform gateway and appends `/v1/messages` itself.
+ if !llm.custom_enabled {
+ if let Some(session) = crate::platform_session::current_platform_session() {
+ return Some(format!(
+ "{}/api/llm/anthropic",
+ session.api_base_url.trim_end_matches('/')
+ ));
+ }
+ }
+ Some(llm.base_url.trim_end_matches('/').to_string())
+ })
.map(|value| {
value
.trim_end_matches('/')
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
index 0b0cbb827..d81e102aa 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
@@ -2351,7 +2351,7 @@ impl CodexAppServerConnection {
)
})?;
effective_llm.base_url =
- format!("{}/api/llm", session.api_base_url.trim_end_matches('/'));
+ format!("{}/api/llm/v1", session.api_base_url.trim_end_matches('/'));
effective_llm.api_key.clear();
// Only the platform catalog identifier reaches Codex. api-server
// validates it and resolves the actual upstream model.
@@ -2619,7 +2619,7 @@ impl CodexAppServerConnection {
..
} => (
Some((
- format!("{}/api/llm", api_base_url.trim_end_matches('/')),
+ format!("{}/api/llm/v1", api_base_url.trim_end_matches('/')),
access_token.clone(),
)),
true,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
index c5f2192cc..b5c910a84 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
@@ -1098,7 +1098,8 @@ fn select_game_creator_model_inner(
if let Some(agent_mode) = catalog_agent_mode {
config.agent_mode = match agent_mode.trim() {
"codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(),
- "cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
+ // `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。
+ "cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
value => return Err(format!("模型 Agent 模式无效:{value}")),
};
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs
index 8a16b71a8..6eb153861 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/config.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs
@@ -222,7 +222,8 @@ fn build_game_creator_official_platform_llm_config(
if api_base_url.is_empty() {
return Err("登录态缺少 API Server 地址".to_string());
}
- let proxy_base_url = format!("{api_base_url}/api/llm");
+ // baseUrl 不带路由与版本段,OpenAI 协议由 platform-llm 自己拼 `v1/`。
+ let proxy_base_url = format!("{api_base_url}/api/llm/v1");
LlmConfig::new(
LlmProvider::OpenAiCompatible,
proxy_base_url,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
index 29268e088..0c479c168 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
@@ -638,6 +638,7 @@ pub(super) fn run() {
set_client_server_selection,
read_profile_recharge_center,
read_profile_wallet_ledger,
+ read_profile_referral_invite_center,
create_profile_recharge_order,
confirm_wechat_profile_recharge_order,
redeem_profile_reward_code,
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx
new file mode 100644
index 000000000..1ab5f56b7
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx
@@ -0,0 +1,48 @@
+import { PlatformProfileReferralModal } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
+import type {
+ AccountReferralController,
+ AccountReferralPanel,
+} from './useAccountReferral';
+
+/** 客户端账号菜单的入口文案是「邀请码 / 玩家社区」,弹层标题跟着入口走。 */
+const CLIENT_REFERRAL_PANEL_TITLE: Record = {
+ invite: '邀请码',
+ community: '玩家社区',
+};
+
+/**
+ * 账号菜单的邀请码 / 玩家社区弹层。
+ *
+ * 弹层实现与主站「我的」页签共用 `packages/shared` 里的同一份
+ * `PlatformProfileReferralModal`(与充值、泥点账单同口径);这里只做宿主适配:
+ * 把 Tauri typed command 读到的邀请中心、会话 origin 补全后的邀请链接、宿主剪贴板
+ * 复制回显和面板开关接到共享组件上,不再另写一份客户端专属弹层。
+ */
+export function AccountReferralDialogs({
+ controller,
+}: {
+ controller: AccountReferralController;
+}) {
+ if (!controller.panel) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
index 145e33445..3af7db68f 100644
--- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
+++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
@@ -41,6 +41,7 @@ import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
+import { AccountReferralDialogs } from './AccountReferral';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
type LauncherNotice,
@@ -52,6 +53,7 @@ import type {
WorkspaceLauncherShellProps,
} from './model';
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
+import { useAccountReferral } from './useAccountReferral';
import { useAccountWallet } from './useAccountWallet';
import {
DESIGN_ARTIFACTS_BUILD_PROMPT,
@@ -73,6 +75,7 @@ export function WorkspaceLauncherShell({
setActiveProjectRuns,
} = useWindowChrome();
const accountWallet = useAccountWallet(currentUser.id);
+ const accountReferral = useAccountReferral(currentUser.id);
const [status, setStatus] = useState('');
const recentProjects = useRecentProjects(setStatus);
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
@@ -741,6 +744,11 @@ export function WorkspaceLauncherShell({
}}
onNoticeRequest={showLauncherNotice}
onRechargeRequest={accountWallet.openRecharge}
+ onReferralRequest={(panel) =>
+ panel === 'invite'
+ ? accountReferral.openInvite()
+ : accountReferral.openCommunity()
+ }
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
onViewChange={navigateLauncher}
/>
@@ -963,6 +971,7 @@ export function WorkspaceLauncherShell({
/>
) : null}
+
{walletSlot ? (
createPortal(
,
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts
new file mode 100644
index 000000000..76c99d35c
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts
@@ -0,0 +1,143 @@
+import { writeText } from '@tauri-apps/plugin-clipboard-manager';
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+
+import type { PlatformProfileReferralCopyState } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
+import type { ProfileReferralInviteCenterResponse } from '../../../../../packages/shared/src/contracts/runtime';
+import { getClientProfileReferralInviteCenter } from '../../services/accountHost';
+
+export type AccountReferralPanel = 'invite' | 'community';
+
+/**
+ * 客户端账号菜单的邀请码 / 玩家社区弹层控制器。
+ *
+ * 邀请码是账号级数据,和泥点余额一样按登录账号隔离:切换账号就丢弃上一位用户的
+ * 邀请中心和复制回显;读取过程中换账号时,晚到的响应直接作废。
+ */
+export function useAccountReferral(currentUserId: string) {
+ const [panel, setPanel] = useState(null);
+ const [center, setCenter] =
+ useState(null);
+ const [inviteUrl, setInviteUrl] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [copyInviteLinkState, setCopyInviteLinkState] =
+ useState('idle');
+ const [copyInviteCodeState, setCopyInviteCodeState] =
+ useState('idle');
+ const lifecycleRef = useRef(0);
+ const currentUserIdRef = useRef(currentUserId);
+
+ useLayoutEffect(() => {
+ currentUserIdRef.current = currentUserId;
+ lifecycleRef.current += 1;
+ }, [currentUserId]);
+
+ useEffect(() => {
+ lifecycleRef.current += 1;
+ setPanel(null);
+ setCenter(null);
+ setInviteUrl(null);
+ setLoading(false);
+ setError(null);
+ setCopyInviteLinkState('idle');
+ setCopyInviteCodeState('idle');
+ }, [currentUserId]);
+
+ async function loadInviteCenter(lifecycle: number, owner: string) {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await getClientProfileReferralInviteCenter();
+ if (
+ lifecycleRef.current !== lifecycle ||
+ currentUserIdRef.current !== owner
+ ) {
+ return;
+ }
+ setCenter(response.center);
+ setInviteUrl(response.inviteUrl);
+ } catch (loadError) {
+ if (
+ lifecycleRef.current !== lifecycle ||
+ currentUserIdRef.current !== owner
+ ) {
+ return;
+ }
+ setCenter(null);
+ setInviteUrl(null);
+ setError(
+ loadError instanceof Error ? loadError.message : '读取邀请码失败',
+ );
+ } finally {
+ if (
+ lifecycleRef.current === lifecycle &&
+ currentUserIdRef.current === owner
+ ) {
+ setLoading(false);
+ }
+ }
+ }
+
+ function openPanel(nextPanel: AccountReferralPanel) {
+ const lifecycle = ++lifecycleRef.current;
+ setPanel(nextPanel);
+ setError(null);
+ setCopyInviteLinkState('idle');
+ setCopyInviteCodeState('idle');
+ if (nextPanel !== 'invite') {
+ return;
+ }
+ void loadInviteCenter(lifecycle, currentUserId);
+ }
+
+ function closePanel() {
+ lifecycleRef.current += 1;
+ setPanel(null);
+ setLoading(false);
+ setCopyInviteLinkState('idle');
+ setCopyInviteCodeState('idle');
+ }
+
+ async function copyValue(
+ applyState: (state: PlatformProfileReferralCopyState) => void,
+ value: string,
+ ) {
+ try {
+ await writeText(value);
+ applyState('copied');
+ } catch {
+ applyState('failed');
+ }
+ }
+
+ function copyInviteCode() {
+ if (!center?.inviteCode) {
+ return;
+ }
+ void copyValue(setCopyInviteCodeState, center.inviteCode);
+ }
+
+ function copyInviteLink() {
+ if (!inviteUrl) {
+ return;
+ }
+ void copyValue(setCopyInviteLinkState, inviteUrl);
+ }
+
+ return {
+ center,
+ copyInviteCodeState,
+ copyInviteCode,
+ copyInviteLink,
+ copyInviteLinkState,
+ error,
+ inviteUrl,
+ loading,
+ panel,
+ openInvite: () => openPanel('invite'),
+ openCommunity: () => openPanel('community'),
+ closePanel,
+ };
+}
+
+export type AccountReferralController = ReturnType;
diff --git a/apps/ai-game-creator-shell/src/services/accountHost.ts b/apps/ai-game-creator-shell/src/services/accountHost.ts
index 479874be9..da82f4168 100644
--- a/apps/ai-game-creator-shell/src/services/accountHost.ts
+++ b/apps/ai-game-creator-shell/src/services/accountHost.ts
@@ -2,6 +2,7 @@ import type {
ConfirmWechatProfileRechargeOrderResponse,
CreateProfileRechargeOrderResponse,
ProfileRechargeCenterResponse,
+ ProfileReferralInviteCenterResponse,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
} from '../../../../packages/shared/src/contracts/runtime';
@@ -40,6 +41,17 @@ export function getClientProfileWalletLedger() {
);
}
+export type ClientProfileReferralInviteCenterResponse = {
+ center: ProfileReferralInviteCenterResponse;
+ inviteUrl: string;
+};
+
+export function getClientProfileReferralInviteCenter() {
+ return invokeAccount(
+ 'read_profile_referral_invite_center',
+ );
+}
+
export function createClientProfileRechargeOrder(productId: string) {
return invokeAccount(
'create_profile_recharge_order',
diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts
index 2f4de6483..2221e5cfd 100644
--- a/apps/ai-game-creator-shell/src/services/clientApi.ts
+++ b/apps/ai-game-creator-shell/src/services/clientApi.ts
@@ -3,7 +3,7 @@ import { resolveTauriInvoke } from '../app/tauri';
export type ClientLlmModel = {
displayName: string;
id: string;
- agentMode: 'codex' | 'cc';
+ agentMode: 'codex' | 'cc' | 'anthropic';
};
export type ClientLlmModelCatalog = {
diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx
index 642cd7a87..621c98708 100644
--- a/apps/ai-game-creator-shell/src/view/layout.tsx
+++ b/apps/ai-game-creator-shell/src/view/layout.tsx
@@ -46,6 +46,7 @@ type LauncherSidebarProps = {
onViewChange: (view: LauncherView) => void;
onNoticeRequest: (title: string) => void;
onRechargeRequest: () => void;
+ onReferralRequest: (panel: 'invite' | 'community') => void;
onRuntimeConfigOpen: () => void;
onLogout: () => void;
};
@@ -56,6 +57,7 @@ type SidebarAccountMenuProps = {
onLogout: () => void;
onNoticeRequest: (title: string) => void;
onRechargeRequest: () => void;
+ onReferralRequest: (panel: 'invite' | 'community') => void;
};
function formatMudPoints(value: number | null | undefined) {
@@ -71,6 +73,7 @@ function SidebarAccountMenu({
onLogout,
onNoticeRequest,
onRechargeRequest,
+ onReferralRequest,
}: SidebarAccountMenuProps) {
const walletBalance = useWalletStore(
(state) => state.mudPointBalance?.totalPoints ?? null,
@@ -164,6 +167,28 @@ function SidebarAccountMenu({
>
使用指南
+
+
) : null}
diff --git a/apps/ai-game-creator-shell/tests/accountHost.test.ts b/apps/ai-game-creator-shell/tests/accountHost.test.ts
index 42f92ba60..7912089d3 100644
--- a/apps/ai-game-creator-shell/tests/accountHost.test.ts
+++ b/apps/ai-game-creator-shell/tests/accountHost.test.ts
@@ -27,6 +27,7 @@ import {
confirmClientWechatProfileRechargeOrder,
createClientProfileRechargeOrder,
getClientProfileRechargeCenter,
+ getClientProfileReferralInviteCenter,
getClientProfileWalletLedger,
redeemClientProfileRewardCode,
} from '../src/services/accountHost';
@@ -47,6 +48,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a
await getClientProfileRechargeCenter();
await getClientProfileWalletLedger();
+ await getClientProfileReferralInviteCenter();
await createClientProfileRechargeOrder('product-1');
await confirmClientWechatProfileRechargeOrder('order-1');
await redeemClientProfileRewardCode('CODE-1');
@@ -54,6 +56,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a
expect(invoke.mock.calls).toEqual([
['read_profile_recharge_center', undefined],
['read_profile_wallet_ledger', undefined],
+ ['read_profile_referral_invite_center', undefined],
['create_profile_recharge_order', { productId: 'product-1' }],
['confirm_wechat_profile_recharge_order', { orderId: 'order-1' }],
['redeem_profile_reward_code', { code: 'CODE-1' }],
diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts
index b304035f9..974693229 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts
@@ -120,6 +120,9 @@ vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
throw new Error('no native clipboard image');
}),
readText: vi.fn(async () => nativeClipboardMock.text),
+ writeText: vi.fn(async (text: string) => {
+ nativeClipboardMock.text = text;
+ }),
}));
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index fd9364151..912be89a7 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -1076,6 +1076,8 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('button', { name: '我的' }));
expect(screen.getByRole('menuitem', { name: '使用指南' })).not.toBeNull();
+ expect(screen.getByRole('menuitem', { name: '邀请码' })).not.toBeNull();
+ expect(screen.getByRole('menuitem', { name: '玩家社区' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '反馈联系' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '退出登录' })).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '帮助' }));
@@ -1090,6 +1092,74 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('menuitem', { name: '使用指南' }));
expect(screen.getByRole('dialog', { name: '使用指南' })).not.toBeNull();
});
+
+ it('opens the invite code and community panels from the account menu', async () => {
+ const inviteCenter = {
+ inviteCode: 'SY12345678',
+ inviteLinkPath: '/?inviteCode=SY12345678',
+ invitedCount: 2,
+ rewardedInviteCount: 2,
+ todayInviterRewardCount: 1,
+ todayInviterRewardRemaining: 9,
+ rewardPoints: 30,
+ invitedUsers: [],
+ hasRedeemedCode: false,
+ boundInviterUserId: null,
+ boundAt: null,
+ updatedAt: '2026-10-01T00:00:00.000Z',
+ };
+ const inviteLink = 'https://dev.genarrative.world/?inviteCode=SY12345678';
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_profile_referral_invite_center') {
+ return { center: inviteCenter, inviteUrl: inviteLink };
+ }
+ return undefined;
+ });
+ window.__TAURI__ = { core: { invoke } };
+ renderLauncherAt('/?launcher');
+
+ fireEvent.click(screen.getByRole('button', { name: '我的' }));
+ fireEvent.click(screen.getByRole('menuitem', { name: '邀请码' }));
+
+ const inviteDialog = await screen.findByRole('dialog', { name: '邀请码' });
+ expect(invoke).toHaveBeenCalledWith(
+ 'read_profile_referral_invite_center',
+ undefined,
+ );
+ expect(within(inviteDialog).getByText('SY12345678')).not.toBeNull();
+ expect(within(inviteDialog).getByText(inviteLink)).not.toBeNull();
+
+ fireEvent.click(
+ within(inviteDialog).getByRole('button', { name: '复制邀请链接' }),
+ );
+ await waitFor(() => {
+ expect(nativeClipboardMock.text).toBe(inviteLink);
+ });
+
+ fireEvent.click(
+ within(inviteDialog).getByRole('button', { name: '复制邀请码' }),
+ );
+ await waitFor(() => {
+ expect(nativeClipboardMock.text).toBe('SY12345678');
+ });
+
+ fireEvent.click(
+ within(inviteDialog).getByRole('button', { name: '关闭邀请码' }),
+ );
+ expect(screen.queryByRole('dialog', { name: '邀请码' })).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: '我的' }));
+ fireEvent.click(screen.getByRole('menuitem', { name: '玩家社区' }));
+ const communityDialog = await screen.findByRole('dialog', {
+ name: '玩家社区',
+ });
+ expect(
+ within(communityDialog).getByAltText('玩家社区微信群二维码'),
+ ).not.toBeNull();
+ expect(
+ within(communityDialog).getByAltText('玩家社区 QQ 群二维码'),
+ ).not.toBeNull();
+ });
}
export function registerHomeProjectCreationTests() {
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index f942f60e2..bd675dbb0 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -59,6 +59,18 @@
> 维护:阶段过程、分支合并和当轮测试数字由 Git 追溯;实现依据以当前代码和最新专题文档为准。
> 格式:参见[决策记录格式](../README.md#决策记录格式),按需填写。
+## 2026-10-01 邀请好友入口回到“我的”页,客户端账号菜单接入邀请码与玩家社区
+
+- 背景:主站“我的”页签的常用功能宫格只剩四项,`邀请好友` 入口在平台个人页恢复时漏了;同一份邀请码在 AGC 客户端里没有任何入口,玩家社区二维码也只存在主站弹层里。
+- 决策(主站):宫格按文档口径恢复五项——泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,`grid-cols-5` 与 `.platform-profile-shortcut-grid` 的五列规则保持一致;邀请好友复用邀请弹层的 `invite` 面板,弹层展示邀请码与完整邀请链接,并拆成“复制邀请码 / 复制邀请链接”两个动作。邀请链接仍由后端 `inviteLinkPath` 加宿主 origin 补全,不在共享组件里拼参数。
+- 决策(跨端复用,重要):邀请弹层下沉到 `packages/shared/src/components/PlatformProfileReferralModal/`(含 `model.ts`、`index.css`、`index.test.tsx` 与 `assets/` 微信群 / QQ 群二维码),与 `PlatformProfileRechargeModal`、`PlatformProfileWalletLedgerModal` 同口径,由主站 `PlatformEntryActiveFlowShell` 和 AGC 客户端 `AccountReferralDialogs` 共用一份实现。共享组件只吃 props(邀请中心事实、补全后的邀请链接、两个复制回显、宿主剪贴板粘贴能力),自己不发请求、不写剪贴板;宿主能力(网页 HostBridge / 客户端 Tauri clipboard-manager)留在各自宿主。客户端弹层标题由宿主覆盖为“邀请码 / 玩家社区”,与账号菜单入口文案保持一致;主站沿用“邀请好友 / 填邀请码 / 玩家社区”。原地删除 `src/components/platform-entry/PlatformProfileReferralModal.tsx` 与客户端专用弹层实现(含 `launcher-referral-*` 样式)。
+- 决策(客户端取数):AGC 侧栏账号菜单在“使用指南”之后新增“邀请码”和“玩家社区”两项。Tauri 侧新增 `read_profile_referral_invite_center`,用当前登录 origin 把 `inviteLinkPath` 补全成完整链接后交给渲染层;客户端只展示邀请码 / 邀请链接和复制动作,不本地另存邀请码、不发放奖励。命令名同时登记进 `scripts/check-config.mjs` 的 native-only 白名单(该门禁要求 `generate_handler!` 里的命令必须被前端调用或显式白名单)。客户端邀请中心按登录账号隔离(`useAccountReferral` 按 `currentUserId` 重置并作废在飞响应)。
+- 决策(奖励说明收进右上角帮助按钮):邀请面板正文不再常驻那段黄色说明,改由标题右侧的帮助按钮(`?`,仅 `invite` 面板)承载悬浮说明;鼠标 `pointerenter` 展开、离开收起,触屏 / 键盘用点击切换(按 `pointerType` 区分,两套触发互不干扰),文案随 `role="note"` 可被无障碍读取。面板正文因此只保留邀请码、邀请链接和成功邀请三段内容。
+- 决策(每日上限文案改为数据驱动):说明文案不再写死“每日最多获得十次”(旧文案只在网页弹层里硬编码,客户端完全没有这句)。共享弹层用后端字段推导:每日上限 = `todayInviterRewardCount + todayInviterRewardRemaining`(当前 10),说明为“邀请一位好友注册,好友每次都能获得 N 泥点 / 好友完成注册后你也可以获得 N 泥点;每天最多 L 次,今日还剩 R 次”,两端自动一致。后端事实:`PROFILE_REFERRAL_REWARD_POINTS = 30`、`PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT = 10` 在 `module-runtime` 域内强制;被邀请人每次都拿满 30,上限只压邀请人一侧,超限后邀请关系仍成立。
+- 已知口径问题(未改,待产品确认):邀请上限的“当日”用的是 `runtime_profile_day_start_micros`(UTC 00:00 边界,即北京时间 08:00 重置),而每日任务 / 每日免费泥点用的是 `runtime_profile_beijing_day_key`(北京时间 00:00)。同一个仓库里两套“每日”口径并存,跨零点前后的奖励计数会与用户直觉不一致。
+- 影响范围:`packages/shared/src/components/PlatformProfileReferralModal/**`、`src/components/platform-entry/PlatformActiveProfileView.tsx`、`PlatformEntryActiveFlowShell.tsx`、`usePlatformProfileCenterController.ts`、`vite.config.ts`(移除已删除文件的 Tailwind `@source`)、`apps/ai-game-creator-shell/src/view/layout.tsx`、`src/features/app-shell/AccountReferral.tsx`、`useAccountReferral.ts`、`WorkspaceLauncher.tsx`、`src/styles.css`、`src-tauri/src/account_api.rs`、`scripts/check-config.mjs`、`src/services/accountHost.ts`、`media/social-media-group/*`(移动到共享组件 `assets/`)、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
+- 验证方式:`npx vitest run packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx src/components/platform-entry apps/ai-game-creator-shell/tests/appSurface.test.ts apps/ai-game-creator-shell/tests/accountHost.test.ts`、`npm run typecheck`(主站,既有 6 处历史报错不变)、`apps/ai-game-creator-shell` 内 `npm run typecheck`(含 check-config 门禁)、`cargo check --tests` 与 `cargo test --bin … account_api::tests`、`cargo fmt --check`、ESLint / Prettier 改动文件、`npm run check:encoding`、`git diff --check`;并在真实 dev 栈上用浏览器与 CDP 附着客户端 WebView2 复截两端弹层,确认同一份实现渲染一致。
+
## 2026-09-29 外壳状态栏退役:`status` 一行改由浮层承载
- 背景:首页输入框下方、项目组页面头部那一行由 `WorkspaceLauncher` 的 `status` 承接,写入的内容很杂——进行中进度(`正在创建工作区`、`正在选择项目`)、“已取消 / 已创建项目 / 已打开项目目录”这类回显、失败结论(`创建未完成,请重试`、工作区看门狗文案、Provider 报错)以及项目组页面的 `正在检查项目状态`。这一行常驻占页面,用户明确要求整行改成浮层提示,而不是只把「已取消」挑出来。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 9cb6ce225..d5d2bf7ef 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -6205,6 +6205,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **验证**:真机尺寸下用 `document.elementFromPoint(按钮中心)` 断言命中的是按钮自身而不是底部菜单(`overlay.parentElement === document.body`);顶栏断点矩阵(320–768)断言单行且无横向溢出。
- **关联**:`src/components/common/SquareImageCropModal.tsx`、`src/components/platform-entry/PlatformProfileModalShell.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/index.css`。
+## 2026-10-01 cc/Anthropic 必须走平台网关,客户端不得直连 Router
+
+- **现象**:目录里把 `claude-opus-5-5` 标成 `agentMode=cc` 后,选它发消息必然失败:先是 Claude CLI 自己打印 `Not logged in · Please run `,后来卡满 `requestTimeoutMs=180000`。
+- **根因**:`claude_code_cli.rs` 用配置里残留的 `llm.baseUrl`(`https://router.genarrative.world/v1`)当 `ANTHROPIC_BASE_URL`,再把平台会话 token 塞成 `ANTHROPIC_AUTH_TOKEN`。平台凭据边界是「客户端只出示平台 access token,账号的 Router key 由 api-server 解析、绝不下发」(见 `api-server/src/llm/mod.rs`)。实测 `POST https://router.genarrative.world/v1/messages` 用平台 token 返回 `401 Invalid token (new_api_error)`。
+- **现行口径**:baseUrl 不带路由与版本段,统一由协议自己拼 `v1/`(服务端 `router_protocol_url` 会先把历史凭据末尾的 `/v1` 归一化掉)。平台侧 Anthropic 是独立路由 `/api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 设成 `{apiBaseUrl}/api/llm/anthropic`;OpenAI 侧是 `/api/llm/v1/responses` 与 `/api/llm/v1/chat/completions`,旧的无 `v1` 路径保留为已发布客户端的兼容别名。
+- **注意**:Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages?beta=true`(外加一次 `HEAD /api/hello` 探测),网关不要自己再补 `v1`,用通配段承接客户端协议路径。
+- **后台协议选项**:`AgcAgentMode` 增加 `anthropic`(显式 Anthropic 协议),`cc` 保留为同一执行器的历史别名;新增目录项应直接写 `anthropic`。
+
## 2026-10-01 AGC 官方 Claude 模型缺少账号令牌会被误报为通道断开
- **现象**:模型目录把回合路由到 `cc`,本地 `game-creator.config.json` 的 `llm.apiKey` 为空时,Claude Agent SDK 返回失败终态;界面只显示“执行通道未能建立或已断开”。
diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
index 2ce2cb439..347575fbf 100644
--- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
+++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
@@ -55,7 +55,8 @@
## 模型绑定 Agent 执行模式
-- 后台 AGC 模型目录每项新增 `agentMode`,只接受 `codex` 与 `cc`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。
+- 后台 AGC 模型目录每项新增 `agentMode`,接受 `codex`、`cc` 与 `anthropic`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。`anthropic` 是 2026-10-01 新增的显式 Anthropic Messages 协议值,`cc` 保留为同一 Claude 执行器的历史别名(两者都映射到 `claude_code_cli`),新目录项应直接写 `anthropic`。
+- 2026-10-01 补充:`cc`/`anthropic` 一律经平台 LLM 网关的独立 Anthropic 路由 `POST /api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 为 `{apiBaseUrl}/api/llm/anthropic`,不得再用配置里的 `llm.baseUrl`(那是 Router 地址,平台不会把账号 Router key 下发给客户端)。Claude Agent SDK 自己拼 `{base}/v1/messages` 并带 `authorization: bearer` 与 `anthropic-version`,网关据此转发上游 `{routerBase}/v1/messages`。
- `codex` 仍表示现有 AGC Codex app-server 执行链路;`cc` 表示 AGC 客户端启动随包的 Claude Agent SDK sidecar,模型的 `modelId` 原样作为 Claude Agent SDK 的模型标识,不把 Claude 协议伪装成 OpenAI Responses。sidecar 随 AGC 安装包携带 SDK 及匹配平台的 Claude Code runtime,用户不需要预先安装 `claude` 命令。
- `/api/llm/models` 的启用模型摘要返回 `agentMode`,后台完整目录、管理 DTO 和客户端目录保持同一绑定快照。客户端在选择模型时同时持久化模型 ID 与执行模式;默认模型变化、模型被停用或目录刷新回退时一并更新执行模式。
- 客户端设置保存不得把后台模型绑定覆盖回 Codex。旧客户端配置缺少执行模式时继续按 Codex 运行;旧后台响应缺少 `agentMode` 时客户端按 Codex 兼容。
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index 5c629527f..fb31bb682 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -78,6 +78,7 @@ npm run check:server-rs-ddd
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
+- 2026-10-01 路由约定:LLM 网关 baseUrl 不带路由与版本段,路径由协议自己拼。OpenAI 侧是 `POST /api/llm/v1/responses` 与 `POST /api/llm/v1/chat/completions`;Anthropic 是独立路由 `POST /api/llm/anthropic/{*path}`(Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages`,网关不自己补 `v1`)。旧的无 `v1` 路径 `/api/llm/responses`、`/api/llm/chat/completions` 保留为已发布客户端的兼容别名。三条路径共用同一凭据边界:客户端只出示平台 access token,账号 Router key 由 api-server 解析且不下发,模型名由目录解析后替换。
- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算现役编辑器精选素材派生授权;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 且已通过、已展示、返还完成的 `editor_showcase_asset` 顶层媒体 / 冻结角色动作帧精确授权读取;动作帧只按快照中的 `assetObjectId` / `objectKey` 逐对象授权,不从 `imageSrc` 或 `generated-*` 前缀推导宽泛权限;动作快照损坏、隐藏、拒绝或不再满足返还条件时不形成帧授权。只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开精选只获得与正式展示快照生命周期一致的精确派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;精选派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。
- 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。
- 编辑器与素材生成:`/api/editor/projects*`、`/api/editor/assets*`、`/api/editor/showcase/*`、`/api/editor/*/generations`、`/api/editor/images/*`、`/api/editor/icon-spritesheets/*`、`/api/editor/ui-designs/*`,以及编辑器 Agent 会话路由。通用任务与素材支撑另保留 `/api/ai/tasks*`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*` 和 `/api/assets/hyper3d/*`。
diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
index f9157429a..20435edeb 100644
--- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md
+++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
@@ -98,7 +98,7 @@ server-rs + Axum + SpacetimeDB
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
9. 现役项目、素材等列表的筛选必须打开独立 dialog / drawer / modal;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
-10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
+10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议五项,按五列铺满整行;入口收缩到四项时按四列铺满整行,不保留五列网格导致左对齐空位。邀请弹层(主站「我的」页签与 AGC 客户端账号菜单共用 `packages/shared` 的 `PlatformProfileReferralModal`)展示邀请码和完整邀请链接,提供“复制邀请码 / 复制邀请链接”两个动作;奖励说明必须从后端 `rewardPoints` 与 `todayInviterRewardCount + todayInviterRewardRemaining` 推导(当前为 30 泥点、每日 10 次、今日剩余 N 次),不在前端写死次数或金额。同一份邀请数据在网页和客户端显示一致,客户端只允许覆盖弹层标题以匹配各自入口文案。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
11. 每日免费泥点由后端独立余额桶承载,基础额度固定为 `20`,按北京时间每日 `00:00` 重置。跨业务日退款时,原消费中的每日免费泥点部分叠加到退款当日每日免费桶,当日余额允许超过 `20`;到下一业务日仍统一失效并重置为 `20`。主站不得把已隐藏的每日任务入口或 `daily_task_reward` 文案继续当作每日免费泥点入口。
12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
diff --git a/media/social-media-group/qq.png b/packages/shared/src/components/PlatformProfileReferralModal/assets/community-qq.png
similarity index 100%
rename from media/social-media-group/qq.png
rename to packages/shared/src/components/PlatformProfileReferralModal/assets/community-qq.png
diff --git a/media/social-media-group/wechat.png b/packages/shared/src/components/PlatformProfileReferralModal/assets/community-wechat.png
similarity index 100%
rename from media/social-media-group/wechat.png
rename to packages/shared/src/components/PlatformProfileReferralModal/assets/community-wechat.png
diff --git a/packages/shared/src/components/PlatformProfileReferralModal/index.css b/packages/shared/src/components/PlatformProfileReferralModal/index.css
new file mode 100644
index 000000000..16829a976
--- /dev/null
+++ b/packages/shared/src/components/PlatformProfileReferralModal/index.css
@@ -0,0 +1,55 @@
+.platform-profile-referral-modal-backdrop {
+ background: rgba(0, 0, 0, 0.42);
+}
+
+.platform-profile-referral-modal {
+ border: 1px solid var(--platform-modal-border);
+ background: #fff;
+ color: #18181b;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.24);
+}
+
+.platform-profile-referral-modal__close {
+ border: 0;
+ background: transparent;
+ color: #ff4056;
+ font-size: 20px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.platform-profile-referral-modal__close:hover {
+ background: rgba(255, 64, 86, 0.08);
+}
+
+/*
+ * 帮助按钮复用平台通用图标按钮 `.platform-icon-button` 的配色(--platform-icon-*),
+ * 这里只把它收到和关闭按钮同高,避免弹层标题栏出现两种尺寸。
+ */
+.platform-icon-button.platform-profile-referral-modal__help {
+ width: 2rem;
+ height: 2rem;
+}
+
+.platform-profile-referral-modal__help-panel {
+ /* 悬浮层必须不透明:PlatformStatusMessage 的 warning 底是半透明色,
+ 直接悬浮会透出下面的邀请码卡片。底色沿用弹层主体的白底,amber 边框 / 文字
+ 继续走平台 warning token。 */
+ border-radius: 0.75rem;
+ background: #fff;
+ box-shadow: 0 12px 30px rgba(112, 57, 30, 0.16);
+}
+
+.platform-profile-referral-modal__avatar {
+ background: #ff4056;
+ color: #fff;
+}
+
+.platform-profile-referral-modal__link {
+ overflow-wrap: anywhere;
+}
+
+.platform-profile-referral-modal__skeleton-item {
+ border-radius: 0.75rem;
+ background: rgba(228, 228, 231, 0.6);
+}
diff --git a/packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx b/packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx
new file mode 100644
index 000000000..c82c6da24
--- /dev/null
+++ b/packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx
@@ -0,0 +1,205 @@
+/* @vitest-environment jsdom */
+
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ComponentProps } from 'react';
+import { describe, expect, test, vi } from 'vitest';
+
+import type { ProfileReferralInviteCenterResponse } from '../../contracts/runtime';
+import { PlatformProfileReferralModal } from './index';
+
+function buildCenter(
+ overrides: Partial = {},
+): ProfileReferralInviteCenterResponse {
+ return {
+ inviteCode: 'ABCD1234',
+ inviteLinkPath: '/invite/ABCD1234',
+ invitedCount: 1,
+ rewardedInviteCount: 1,
+ todayInviterRewardCount: 1,
+ todayInviterRewardRemaining: 9,
+ rewardPoints: 30,
+ invitedUsers: [
+ {
+ userId: 'user-2',
+ displayName: '海盐',
+ avatarUrl: null,
+ boundAt: '2026-06-10T08:00:00.000Z',
+ },
+ ],
+ hasRedeemedCode: false,
+ boundInviterUserId: null,
+ boundAt: null,
+ updatedAt: '2026-06-10T08:00:00.000Z',
+ ...overrides,
+ };
+}
+
+function renderPanel(
+ panel: 'invite' | 'redeem' | 'community',
+ overrides: Partial> = {},
+) {
+ return render(
+ ,
+ );
+}
+
+describe('PlatformProfileReferralModal', () => {
+ test('renders invite panel with invite code, invite link and both copy actions', () => {
+ const onCopyInviteLink = vi.fn();
+ const onCopyInviteCode = vi.fn();
+ renderPanel('invite', { onCopyInviteLink, onCopyInviteCode });
+
+ const dialog = screen.getByRole('dialog', { name: '邀请好友' });
+
+ expect(within(dialog).getByText('邀请码')).toBeTruthy();
+ expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
+ expect(within(dialog).getByText('海盐')).toBeTruthy();
+ expect(within(dialog).getByText('成功邀请')).toBeTruthy();
+ expect(
+ within(dialog).getByText('https://www.genarrative.world/invite/ABCD1234'),
+ ).toBeTruthy();
+
+ fireEvent.click(
+ within(dialog).getByRole('button', { name: '复制邀请链接' }),
+ );
+ expect(onCopyInviteLink).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(within(dialog).getByRole('button', { name: '复制邀请码' }));
+ expect(onCopyInviteCode).toHaveBeenCalledTimes(1);
+ });
+
+ test('奖励说明收进右上角帮助按钮,hover 展开并派生每日上限', async () => {
+ const user = userEvent.setup();
+ renderPanel('invite', {
+ center: buildCenter({
+ todayInviterRewardCount: 4,
+ todayInviterRewardRemaining: 6,
+ rewardPoints: 66,
+ }),
+ });
+
+ const dialog = screen.getByRole('dialog', { name: '邀请好友' });
+ const inviteeLine = '邀请一位好友注册,好友每次都能获得 66 泥点。';
+ const inviterLine =
+ '好友完成注册后你也可以获得 66 泥点;每天最多 10 次,今日还剩 6 次。';
+
+ // 说明不再常驻面板正文,只在悬浮 / 展开时出现。
+ expect(within(dialog).queryByText(inviteeLine)).toBeNull();
+ expect(within(dialog).queryByText(inviterLine)).toBeNull();
+
+ const helpButton = within(dialog).getByRole('button', {
+ name: '邀请奖励说明',
+ });
+ await user.hover(helpButton);
+
+ const helpPanel = within(dialog).getByRole('note', {
+ name: '邀请奖励说明',
+ });
+ expect(within(helpPanel).getByText(inviteeLine)).toBeTruthy();
+ expect(within(helpPanel).getByText(inviterLine)).toBeTruthy();
+ });
+
+ test('触屏 / 键盘可以点开帮助说明(无 hover 事件)', () => {
+ renderPanel('invite');
+
+ const dialog = screen.getByRole('dialog', { name: '邀请好友' });
+ const helpButton = within(dialog).getByRole('button', {
+ name: '邀请奖励说明',
+ });
+
+ fireEvent.click(helpButton);
+ expect(
+ within(dialog).getByRole('note', { name: '邀请奖励说明' }),
+ ).toBeTruthy();
+
+ fireEvent.click(helpButton);
+ expect(
+ within(dialog).queryByRole('note', { name: '邀请奖励说明' }),
+ ).toBeNull();
+ });
+
+ test('玩家社区面板不显示邀请奖励帮助', () => {
+ renderPanel('community');
+
+ const dialog = screen.getByRole('dialog', { name: '玩家社区' });
+ expect(
+ within(dialog).queryByRole('button', { name: '邀请奖励说明' }),
+ ).toBeNull();
+ });
+
+ test('复制状态按动作各自回显', () => {
+ renderPanel('invite', {
+ copyInviteCodeState: 'copied',
+ copyInviteLinkState: 'failed',
+ });
+
+ const dialog = screen.getByRole('dialog', { name: '邀请好友' });
+
+ expect(within(dialog).getByText('已复制')).toBeTruthy();
+ expect(within(dialog).getByText('复制失败')).toBeTruthy();
+ });
+
+ test('submits redeem panel with the shared form shell', async () => {
+ const user = userEvent.setup();
+ const onRedeemCodeChange = vi.fn();
+ const onSubmitRedeemCode = vi.fn();
+
+ renderPanel('redeem', {
+ redeemCode: 'ab12',
+ onRedeemCodeChange,
+ onSubmitRedeemCode,
+ });
+
+ const dialog = screen.getByRole('dialog', { name: '填邀请码' });
+ const input = within(dialog).getByRole('textbox', { name: '邀请码' });
+
+ await user.type(input, ' c');
+ expect(onRedeemCodeChange).toHaveBeenCalled();
+
+ await user.click(within(dialog).getByRole('button', { name: '提交' }));
+ expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
+ });
+
+ test('redeem panel offers paste when the host declares the capability', async () => {
+ const user = userEvent.setup();
+ const onPasteRedeemCode = vi.fn();
+
+ renderPanel('redeem', { canPasteRedeemCode: true, onPasteRedeemCode });
+ await user.click(screen.getByRole('button', { name: '粘贴' }));
+ expect(onPasteRedeemCode).toHaveBeenCalledTimes(1);
+ });
+
+ test('redeem panel hides paste when the host cannot read the clipboard', () => {
+ renderPanel('redeem', { canPasteRedeemCode: false });
+ expect(screen.queryByRole('button', { name: '粘贴' })).toBeNull();
+ });
+
+ test('renders community QR panels', () => {
+ renderPanel('community');
+
+ const dialog = screen.getByRole('dialog', { name: '玩家社区' });
+
+ expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
+ expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
+ expect(within(dialog).getByText('微信群')).toBeTruthy();
+ expect(within(dialog).getByText('QQ群')).toBeTruthy();
+ });
+});
diff --git a/packages/shared/src/components/PlatformProfileReferralModal/index.tsx b/packages/shared/src/components/PlatformProfileReferralModal/index.tsx
new file mode 100644
index 000000000..8b0e62b80
--- /dev/null
+++ b/packages/shared/src/components/PlatformProfileReferralModal/index.tsx
@@ -0,0 +1,529 @@
+import './index.css';
+
+import { CircleHelp, ClipboardPaste, Copy } from 'lucide-react';
+import {
+ type PointerEvent,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useId,
+ useState,
+} from 'react';
+
+import type { ProfileReferralInviteCenterResponse } from '../../contracts/runtime';
+import { PlatformActionButton } from '../PlatformActionButton';
+import { PlatformAsyncStatePanel } from '../PlatformAsyncStatePanel';
+import { PlatformEmptyState } from '../PlatformEmptyState';
+import { PlatformFieldLabel } from '../PlatformFieldLabel';
+import { PlatformIconButton } from '../PlatformIconButton';
+import { PlatformStatusMessage } from '../PlatformStatusMessage';
+import { PlatformSubpanel } from '../PlatformSubpanel';
+import { PlatformTextField } from '../PlatformTextField';
+import communityQqQrImage from './assets/community-qq.png';
+import communityWechatQrImage from './assets/community-wechat.png';
+import {
+ buildPlatformProfileReferralRewardCopy,
+ normalizePlatformProfileReferralRedeemCode,
+ type PlatformProfileReferralCopyState,
+ type PlatformProfileReferralPanel,
+} from './model';
+
+export type {
+ PlatformProfileReferralCopyState,
+ PlatformProfileReferralPanel,
+} from './model';
+
+export type PlatformProfileReferralModalProps = {
+ panel: PlatformProfileReferralPanel;
+ /**
+ * 弹层标题。默认按 panel 取「邀请好友 / 填邀请码 / 玩家社区」;
+ * 入口文案不同的宿主(例如客户端菜单叫「邀请码」)可以覆盖,保持入口与弹层自洽。
+ */
+ title?: string;
+ center: ProfileReferralInviteCenterResponse | null;
+ /** 宿主补全站点 origin 后的完整邀请链接;后端只返回站内相对路径。 */
+ inviteLinkUrl: string | null;
+ isLoading: boolean;
+ isSubmittingRedeem: boolean;
+ redeemCode: string;
+ copyInviteLinkState: PlatformProfileReferralCopyState;
+ copyInviteCodeState: PlatformProfileReferralCopyState;
+ error: string | null;
+ success: string | null;
+ /** 宿主能力:声明可读取宿主剪贴板时才显示「粘贴」。 */
+ canPasteRedeemCode?: boolean;
+ onPasteRedeemCode?: () => void;
+ onClose: () => void;
+ onCopyInviteLink: () => void;
+ onCopyInviteCode: () => void;
+ /** 不支持「填邀请码」的宿主可以整体省略;省略时不渲染填码表单。 */
+ onRedeemCodeChange?: (value: string) => void;
+ onSubmitRedeemCode?: () => void;
+};
+
+const COMMUNITY_QR_CODES = [
+ { label: '微信群', src: communityWechatQrImage, alt: '玩家社区微信群二维码' },
+ { label: 'QQ群', src: communityQqQrImage, alt: '玩家社区 QQ 群二维码' },
+] as const;
+
+function resolvePanelTitle(panel: PlatformProfileReferralPanel) {
+ if (panel === 'invite') {
+ return '邀请好友';
+ }
+ if (panel === 'redeem') {
+ return '填邀请码';
+ }
+ return '玩家社区';
+}
+
+/**
+ * 复制回显按钮。
+ *
+ * 复制实现和状态由宿主持有(网页走 HostBridge / navigator.clipboard,客户端走
+ * Tauri clipboard-manager),这里只按状态渲染文案。
+ */
+function ReferralCopyButton({
+ state,
+ idleLabel,
+ copiedLabel = '已复制',
+ disabled,
+ className,
+ buttonProps,
+ idleIcon,
+ tone = 'primary',
+ size = 'md',
+ shape = 'default',
+ fullWidth = true,
+}: {
+ state: PlatformProfileReferralCopyState;
+ idleLabel: string;
+ copiedLabel?: string;
+ disabled?: boolean;
+ className: string;
+ buttonProps?: Record;
+ idleIcon: ReactNode;
+ tone?: 'primary' | 'ghost';
+ size?: 'xs' | 'md';
+ shape?: 'default' | 'pill';
+ fullWidth?: boolean;
+}) {
+ const label =
+ state === 'copied'
+ ? copiedLabel
+ : state === 'failed'
+ ? '复制失败'
+ : idleLabel;
+
+ return (
+
+ {state === 'copied' ? null : idleIcon}
+ {label}
+
+ );
+}
+
+function ReferralSkeleton({ variant }: { variant: 'invite' | 'redeem' }) {
+ return (
+
+ );
+}
+
+function ProfileReferralInvitedUserRow({
+ name,
+ avatarUrl,
+}: {
+ name: string;
+ avatarUrl: string | null;
+}) {
+ const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
+
+ return (
+
+
+ {avatarUrl ? (
+
+ ) : (
+ avatarLabel
+ )}
+
+
+
+ );
+}
+
+/**
+ * 个人中心邀请能力统一弹层:邀请码、填邀请码和玩家社区三种 panel。
+ *
+ * 主站「我的」页签和 AGC 客户端账号菜单共用这一份实现(与充值 / 泥点账单弹层同口径),
+ * 弹层只渲染宿主传入的事实,不自己读邀请中心、不自己写剪贴板。
+ */
+export function PlatformProfileReferralModal({
+ panel,
+ title: titleOverride,
+ center,
+ inviteLinkUrl,
+ isLoading,
+ isSubmittingRedeem,
+ redeemCode,
+ copyInviteLinkState,
+ copyInviteCodeState,
+ error,
+ success,
+ canPasteRedeemCode = false,
+ onPasteRedeemCode,
+ onClose,
+ onCopyInviteLink,
+ onCopyInviteCode,
+ onRedeemCodeChange,
+ onSubmitRedeemCode,
+}: PlatformProfileReferralModalProps) {
+ const titleId = useId();
+ const title = titleOverride ?? resolvePanelTitle(panel);
+ const normalizedRedeemCode =
+ normalizePlatformProfileReferralRedeemCode(redeemCode);
+ const rewardCopy = buildPlatformProfileReferralRewardCopy(center);
+ // 鼠标走 hover,触屏 / 键盘走点击,两套触发互不干扰。
+ const [isRewardHelpHovered, setIsRewardHelpHovered] = useState(false);
+ const [isRewardHelpPinned, setIsRewardHelpPinned] = useState(false);
+ const isRewardHelpOpen = isRewardHelpHovered || isRewardHelpPinned;
+ const handleRewardHelpPointerEnter = useCallback(
+ (event: PointerEvent) => {
+ if (event.pointerType === 'mouse') {
+ setIsRewardHelpHovered(true);
+ }
+ },
+ [],
+ );
+ const handleRewardHelpPointerLeave = useCallback(
+ (event: PointerEvent) => {
+ if (event.pointerType === 'mouse') {
+ setIsRewardHelpHovered(false);
+ setIsRewardHelpPinned(false);
+ }
+ },
+ [],
+ );
+ const handleRewardHelpBlur = useCallback(() => {
+ setIsRewardHelpHovered(false);
+ setIsRewardHelpPinned(false);
+ }, []);
+ const handleRewardHelpFocus = useCallback(
+ () => setIsRewardHelpHovered(true),
+ [],
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && !event.defaultPrevented) {
+ onClose();
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ let content: ReactNode;
+
+ if (panel === 'community') {
+ content = (
+
+ {COMMUNITY_QR_CODES.map((qrCode) => (
+
+
+ 
+
+
+ {qrCode.label}
+
+
+ ))}
+
+ );
+ } else if (panel === 'redeem' && onRedeemCodeChange && onSubmitRedeemCode) {
+ content = (
+ }
+ isEmpty={Boolean(center?.hasRedeemedCode)}
+ emptyState={
+
+ 已填写邀请码
+
+ }
+ >
+
+
+ );
+ } else {
+ content = (
+ }
+ >
+
+
+
+ 邀请码
+
+
+ {center?.inviteCode ?? '--------'}
+
+ }
+ buttonProps={{ onClick: onCopyInviteCode }}
+ />
+
+
+
+ 邀请链接
+
+
+ {inviteLinkUrl ?? '正在生成邀请链接'}
+
+
+ }
+ buttonProps={{ onClick: onCopyInviteLink }}
+ />
+
+
+ 成功邀请
+
+ {center?.invitedUsers?.length ? (
+
+ {center.invitedUsers.map((user) => (
+
+ ))}
+
+ ) : (
+
+ 暂无成功邀请
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+ {
+ if (event.target === event.currentTarget) {
+ onClose();
+ }
+ }}
+ >
+
+ {/*
+ 奖励说明不再占用面板正文,收进右上角的帮助按钮:桌面 hover 即出,
+ 触屏 / 键盘用点击展开,所以按钮同时是 hover 与点击两种触发。
+ */}
+ {panel === 'invite' && rewardCopy ? (
+
+ }
+ onFocus={handleRewardHelpFocus}
+ onBlur={handleRewardHelpBlur}
+ onClick={() => setIsRewardHelpPinned((pinned) => !pinned)}
+ />
+ {isRewardHelpOpen ? (
+
+
+ {rewardCopy.inviteeRewardLine}
+ {rewardCopy.inviterRewardLine}
+
+
+ ) : null}
+
+ ) : null}
+
+
+ {title}
+
+ {content}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {success ? (
+
+ {success}
+
+ ) : null}
+
+
+ );
+}
+
+export default PlatformProfileReferralModal;
diff --git a/packages/shared/src/components/PlatformProfileReferralModal/model.ts b/packages/shared/src/components/PlatformProfileReferralModal/model.ts
new file mode 100644
index 000000000..dfa3b47f2
--- /dev/null
+++ b/packages/shared/src/components/PlatformProfileReferralModal/model.ts
@@ -0,0 +1,53 @@
+import type { ProfileReferralInviteCenterResponse } from '../../contracts/runtime';
+
+export type PlatformProfileReferralPanel = 'invite' | 'redeem' | 'community';
+export type PlatformProfileReferralCopyState = 'idle' | 'copied' | 'failed';
+
+export function normalizePlatformProfileReferralRedeemCode(value: string) {
+ return value
+ .trim()
+ .replace(/[^0-9a-z]/gi, '')
+ .toUpperCase();
+}
+
+/**
+ * 邀请奖励的当日额度由后端字段推导。
+ *
+ * 后端只返回「今日已获得次数」和「今日剩余次数」,两者相加就是当前生效的每日上限
+ * (当前 `PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT = 10`)。前端不再另存一份写死的
+ * `10`:上限调整时两端文案自动跟着走。
+ */
+export function resolvePlatformProfileReferralDailyLimit(
+ center: ProfileReferralInviteCenterResponse | null,
+) {
+ if (!center) {
+ return null;
+ }
+ return center.todayInviterRewardCount + center.todayInviterRewardRemaining;
+}
+
+/**
+ * 邀请面板的奖励说明。
+ *
+ * 语义边界(后端 `redeem_profile_referral_invite_code`):被邀请人每次都拿满
+ * `rewardPoints`;每日上限只压邀请人一侧,超过上限后邀请人当日不再获得奖励,
+ * 邀请关系本身仍然成立。
+ */
+export function buildPlatformProfileReferralRewardCopy(
+ center: ProfileReferralInviteCenterResponse | null,
+) {
+ if (!center) {
+ return null;
+ }
+ const dailyLimit = resolvePlatformProfileReferralDailyLimit(center);
+ return {
+ rewardPoints: center.rewardPoints,
+ dailyLimit,
+ remainingToday: center.todayInviterRewardRemaining,
+ inviteeRewardLine: `邀请一位好友注册,好友每次都能获得 ${center.rewardPoints} 泥点。`,
+ inviterRewardLine:
+ dailyLimit === null
+ ? `好友完成注册后你也可以获得 ${center.rewardPoints} 泥点。`
+ : `好友完成注册后你也可以获得 ${center.rewardPoints} 泥点;每天最多 ${dailyLimit} 次,今日还剩 ${center.todayInviterRewardRemaining} 次。`,
+ };
+}
diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs
index 541e0c06e..ab074c958 100644
--- a/scripts/check-native-shells.mjs
+++ b/scripts/check-native-shells.mjs
@@ -292,8 +292,8 @@ const h5HostBridgeCallChainWrapperFiles = [
const h5HostBridgeRequiredCallChainFiles = [
'src/ActiveApp.tsx',
'src/components/auth/AuthGate.tsx',
+ 'src/components/platform-entry/PlatformEntryActiveFlowShell.tsx',
'src/components/platform-entry/PlatformFeedbackView.tsx',
- 'src/components/platform-entry/PlatformProfileReferralModal.tsx',
'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx',
'src/components/platform-entry/usePlatformProfileCenterController.ts',
'src/hooks/useHostNavigationCanGoBack.ts',
diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs
index 439ddbd7e..3ff4dc75c 100644
--- a/server-rs/crates/api-server/src/llm/mod.rs
+++ b/server-rs/crates/api-server/src/llm/mod.rs
@@ -365,7 +365,7 @@ pub async fn proxy_llm_responses(
.with_message(format!("创建 LLM Router 请求客户端失败:{error}")),
)
})?;
- let upstream_url = format!("{}/responses", base_url.trim_end_matches('/'));
+ let upstream_url = router_protocol_url(&base_url, "responses");
let mut request = client
.post(upstream_url)
.bearer_auth(api_key)
@@ -470,6 +470,227 @@ pub async fn proxy_llm_responses(
)
}
+/// Proxies the Anthropic Messages protocol for the LLM Router.
+///
+/// Credential boundary is identical to [`proxy_llm_responses`]: the caller only
+/// presents the platform access token, and the account-owned Router credential
+/// is resolved inside api-server and never returned to the client. The Claude
+/// Agent SDK appends `/v1/messages` to `ANTHROPIC_BASE_URL`, so this route is
+/// registered at that exact path and only rewrites `model`.
+pub async fn proxy_llm_messages(
+ State(state): State,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Result {
+ if body.len() > LLM_REQUEST_MAX_BODY_BYTES {
+ return Err(llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE)
+ .with_message("LLM Messages 请求体超过大小限制"),
+ ));
+ }
+
+ if let Err(error) =
+ ensure_llm_router_user_can_start_conversation(&state, authenticated.claims().user_id())
+ .await
+ {
+ return Err(llm_error_response(&request_context, error));
+ }
+
+ let mut payload = serde_json::from_slice::(&body).map_err(|_| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::BAD_REQUEST)
+ .with_message("LLM Messages 请求体必须是合法 JSON"),
+ )
+ })?;
+ let object = payload.as_object_mut().ok_or_else(|| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::BAD_REQUEST)
+ .with_message("LLM Messages 请求体必须是 JSON 对象"),
+ )
+ })?;
+ let requested_model = object
+ .get("model")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string);
+ // Same boundary as Responses: provider/credential controls never reach the
+ // Router, even when an older desktop build still sends them.
+ for field in [
+ "apiKey",
+ "api_key",
+ "baseUrl",
+ "base_url",
+ "provider",
+ "apiKind",
+ "api_kind",
+ "agentLlm",
+ "agent_llm",
+ "agentMode",
+ "agent_mode",
+ ] {
+ object.remove(field);
+ }
+ let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
+ .await
+ .map_err(|error| llm_error_response(&request_context, error))?;
+ // 这条路由只服务 AGC 的 Anthropic 执行器。Claude Agent SDK 直连网关、不会补
+ // `x-genarrative-client` 标记,所以这里直接按目录解析客户端选的稳定标识;
+ // 目录外取值仍由 `resolve_requested` 拒绝。
+ let requested_model = requested_model.as_deref();
+ let selected_model = catalog
+ .resolve_requested(requested_model)
+ .map_err(|message| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message),
+ )
+ })?
+ .to_string();
+ object.insert("model".to_string(), Value::String(selected_model));
+
+ let (base_url, api_key, key_id) =
+ resolve_llm_router_credentials(&state, authenticated.claims().user_id())
+ .await
+ .map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error),
+ )
+ })?;
+
+ prepare_llm_router_billing(&state, authenticated.claims().user_id())
+ .await
+ .map_err(|error| llm_error_response(&request_context, error))?;
+
+ let client = reqwest::Client::builder()
+ .connect_timeout(std::time::Duration::from_secs(15))
+ .read_timeout(std::time::Duration::from_secs(180))
+ .redirect(reqwest::redirect::Policy::none())
+ .build()
+ .map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
+ .with_message(format!("创建 LLM Router 请求客户端失败:{error}")),
+ )
+ })?;
+ let upstream_url = router_protocol_url(&base_url, "messages");
+ // The Router speaks Anthropic Messages on this path; accept both credential
+ // headers because the SDK uses `x-api-key` while the gateway also allows
+ // `Authorization: Bearer`.
+ let mut request = client
+ .post(upstream_url)
+ .header("x-api-key", api_key.clone())
+ .bearer_auth(api_key)
+ .header("content-type", "application/json");
+ for name in ["anthropic-version", "anthropic-beta", "accept"] {
+ if let Some(value) = headers.get(name) {
+ request = request.header(name, value);
+ }
+ }
+ let upstream = request
+ .body(serde_json::to_vec(&payload).map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
+ .with_message(format!("序列化 LLM Messages 请求失败:{error}")),
+ )
+ })?)
+ .send()
+ .await
+ .map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::BAD_GATEWAY)
+ .with_message(format!("LLM Router 暂时不可用:{error}")),
+ )
+ })?;
+
+ let status = upstream.status();
+ if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
+ if let Err(error) = crate::external_api_keys::revoke_llm_router_account(
+ &state,
+ authenticated.claims().user_id(),
+ &key_id,
+ )
+ .await
+ {
+ tracing::warn!(
+ request_id = request_context.request_id(),
+ user_id = %authenticated.claims().user_id(),
+ key_id = %key_id,
+ error = %error,
+ "LLM Router 返回确定鉴权失败,但本地账号 Key 失效标记未完成"
+ );
+ }
+ }
+ let upstream_headers = upstream.headers().clone();
+ let is_stream = payload
+ .get("stream")
+ .and_then(Value::as_bool)
+ .unwrap_or(false);
+ if !is_stream {
+ let body = upstream.bytes().await.map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::BAD_GATEWAY)
+ .with_message(format!("读取 LLM Router 响应失败:{error}")),
+ )
+ })?;
+ if status.is_success() {
+ if let Err(error) =
+ settle_llm_router_usage(&state, authenticated.claims().user_id()).await
+ {
+ tracing::error!(
+ request_id = request_context.request_id(),
+ user_id = %authenticated.claims().user_id(),
+ error = %error,
+ "LLM Router Messages 成功但累计额度同步未完成"
+ );
+ }
+ }
+ return build_upstream_response(
+ status,
+ &upstream_headers,
+ Body::from(body),
+ &request_context,
+ );
+ }
+
+ if !status.is_success() {
+ let stream = upstream.bytes_stream().map(|chunk| {
+ chunk.map_err(|error| {
+ std::io::Error::other(format!("LLM Router 响应流读取失败:{error}"))
+ })
+ });
+ return build_upstream_response(
+ status,
+ &upstream_headers,
+ Body::from_stream(stream),
+ &request_context,
+ );
+ }
+
+ let stream = stream_messages_with_billing(
+ upstream.bytes_stream(),
+ state.clone(),
+ authenticated.claims().user_id().to_string(),
+ request_context.request_id().to_string(),
+ );
+ build_upstream_response(
+ status,
+ &upstream_headers,
+ Body::from_stream(stream),
+ &request_context,
+ )
+}
+
fn build_upstream_response(
status: StatusCode,
upstream_headers: &HeaderMap,
@@ -666,6 +887,17 @@ fn is_responses_done_sse_event(event: &str) -> bool {
.any(|line| matches!(line.trim(), "data: [DONE]" | "[DONE]"))
}
+/// Anthropic Messages ends a stream with `message_stop` and has no `[DONE]`
+/// sentinel, so the terminal event is the only settle point.
+fn is_messages_terminal_sse_event(event: &str) -> bool {
+ event.lines().any(|line| {
+ let line = line.trim();
+ line == "event: message_stop"
+ || line.contains("\"type\":\"message_stop\"")
+ || line.contains("\"type\": \"message_stop\"")
+ })
+}
+
fn finalize_llm_router_terminal_event(
terminal_event: &mut Option,
billing_result: Option<&Result<(), AppError>>,
@@ -816,6 +1048,77 @@ fn stream_responses_with_billing(
}
}
+/// Passes an Anthropic Messages SSE stream through unchanged and settles the
+/// account quota once the upstream signals `message_stop`.
+///
+/// The bytes are forwarded verbatim so the Claude Agent SDK keeps parsing the
+/// same event shapes it received before the platform gateway was introduced.
+fn stream_messages_with_billing(
+ mut upstream: impl futures_util::Stream- > + Unpin,
+ state: AppState,
+ owner_user_id: String,
+ request_id: String,
+) -> impl futures_util::Stream
- > {
+ async_stream::stream! {
+ let mut pending = String::new();
+ let mut utf8_pending = Vec::new();
+ let mut billing_result = None;
+
+ while let Some(chunk) = upstream.next().await {
+ match chunk {
+ Ok(bytes) => {
+ append_utf8_chunk(&mut pending, &mut utf8_pending, bytes.as_ref());
+ pending = pending.replace("\r\n", "\n");
+ while let Some(separator) = pending.find("\n\n") {
+ let raw_event = pending[..separator + 2].to_string();
+ let event = pending[..separator].to_string();
+ pending.drain(..separator + 2);
+ if is_messages_terminal_sse_event(&event) && billing_result.is_none() {
+ billing_result = Some(settle_llm_router_usage(
+ &state,
+ owner_user_id.as_str(),
+ ).await);
+ if let Some(Err(error)) = billing_result.as_ref() {
+ tracing::error!(
+ request_id = %request_id,
+ user_id = %owner_user_id,
+ error = %error,
+ "LLM Router 流式响应已完成但累计额度同步未完成"
+ );
+ }
+ }
+ yield Ok(Bytes::from(raw_event));
+ }
+ }
+ Err(error) => {
+ if billing_result.is_none() {
+ if let Err(billing_error) = settle_llm_router_usage(
+ &state,
+ owner_user_id.as_str(),
+ )
+ .await
+ {
+ tracing::error!(
+ request_id = %request_id,
+ user_id = %owner_user_id,
+ error = %billing_error,
+ "LLM Router 流式响应中断且累计额度同步未完成"
+ );
+ }
+ }
+ yield Err(std::io::Error::other(format!(
+ "LLM Router 响应流读取失败:{error}"
+ )));
+ return;
+ }
+ }
+ }
+ if !pending.is_empty() {
+ yield Ok(Bytes::from(pending));
+ }
+ }
+}
+
fn append_utf8_chunk(pending: &mut String, carry: &mut Vec, bytes: &[u8]) {
carry.extend_from_slice(bytes);
loop {
@@ -1082,6 +1385,21 @@ fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
LlmMessage::new(role, message.content)
}
+/// Composes the Router endpoint for one protocol operation.
+///
+/// The account credential stored for the AGC official route is a bare gateway
+/// base: it must not carry the version segment or the operation path, so the
+/// protocol composes `v1/` itself. Historical rows still store a
+/// trailing `/v1`, so it is normalised away first instead of being duplicated.
+fn router_protocol_url(base_url: &str, operation: &str) -> String {
+ let base = base_url.trim().trim_end_matches('/');
+ let base = base
+ .strip_suffix("/v1")
+ .unwrap_or(base)
+ .trim_end_matches('/');
+ format!("{base}/v1/{operation}")
+}
+
fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -1387,7 +1705,8 @@ mod tests {
.expect("captured request lock")
.clone()
.expect("mock server should capture upstream request");
- assert!(upstream_request.starts_with("POST /responses HTTP/1.1"));
+ // baseUrl 不带路由与版本段,Responses 由协议自己拼 `v1/responses`。
+ assert!(upstream_request.starts_with("POST /v1/responses HTTP/1.1"));
assert!(
upstream_request
.lines()
@@ -1657,6 +1976,145 @@ mod tests {
assert!(carry.is_empty());
}
+ #[test]
+ fn router_protocol_url_composes_v1_without_duplicating_it() {
+ // baseUrl 不带路由与版本段,协议自己拼。
+ assert_eq!(
+ router_protocol_url("https://router.example", "messages"),
+ "https://router.example/v1/messages"
+ );
+ assert_eq!(
+ router_protocol_url("https://router.example", "responses"),
+ "https://router.example/v1/responses"
+ );
+ // 历史凭据里带着 `/v1`,归一化掉而不是拼成 `/v1/v1/...`。
+ assert_eq!(
+ router_protocol_url("https://router.example/v1", "messages"),
+ "https://router.example/v1/messages"
+ );
+ assert_eq!(
+ router_protocol_url("https://router.example/v1/", "chat/completions"),
+ "https://router.example/v1/chat/completions"
+ );
+ }
+
+ #[tokio::test]
+ async fn llm_v1_responses_route_reaches_the_router_v1_path() {
+ let (server_url, captured) = spawn_capturing_mock_server(MockResponse {
+ status_line: "200 OK",
+ content_type: "application/json; charset=utf-8",
+ body: r#"{"id":"resp_v1_01","status":"completed","output":[]}"#.to_string(),
+ extra_headers: Vec::new(),
+ });
+ let (state, user_id) = seed_authenticated_state(AppConfig {
+ llm_router_base_url: server_url.clone(),
+ ..AppConfig::default()
+ })
+ .await;
+ install_test_provisioned_router_credential(&user_id, server_url, "router-key");
+ let token = issue_access_token(&state, &user_id);
+ let app = build_router(state);
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/api/llm/v1/responses")
+ .header("authorization", format!("Bearer {token}"))
+ .header("content-type", "application/json")
+ .header("x-genarrative-response-envelope", "v1")
+ .body(Body::from(
+ json!({ "input": [{ "role": "user", "content": "ping" }] }).to_string(),
+ ))
+ .expect("request should build"),
+ )
+ .await
+ .expect("request should succeed");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let captured_request = captured
+ .lock()
+ .expect("captured request lock should not poison")
+ .clone()
+ .expect("upstream request should be captured");
+ assert!(
+ captured_request
+ .to_ascii_lowercase()
+ .starts_with("post /v1/responses"),
+ "canonical v1 route must reach the Router v1 path: {captured_request}"
+ );
+ }
+
+ #[tokio::test]
+ async fn llm_anthropic_messages_uses_account_router_credential() {
+ let (server_url, captured) = spawn_capturing_mock_server(MockResponse {
+ status_line: "200 OK",
+ content_type: "application/json; charset=utf-8",
+ body: r#"{"id":"msg_api_server_01","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}]}"#.to_string(),
+ extra_headers: Vec::new(),
+ });
+ let (state, user_id) = seed_authenticated_state(AppConfig {
+ llm_router_base_url: server_url.clone(),
+ ..AppConfig::default()
+ })
+ .await;
+ install_test_provisioned_router_credential(&user_id, server_url, "router-key");
+ let token = issue_access_token(&state, &user_id);
+ let app = build_router(state);
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/api/llm/anthropic/v1/messages")
+ .header("authorization", format!("Bearer {token}"))
+ .header("content-type", "application/json")
+ .header("anthropic-version", "2023-06-01")
+ .header("x-genarrative-client", "agc")
+ .body(Body::from(
+ json!({
+ "max_tokens": 16,
+ "messages": [{ "role": "user", "content": "ping" }]
+ })
+ .to_string(),
+ ))
+ .expect("request should build"),
+ )
+ .await
+ .expect("request should succeed");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body should collect")
+ .to_bytes();
+ assert!(
+ String::from_utf8_lossy(&body).contains("msg_api_server_01"),
+ "anthropic payload should pass through"
+ );
+
+ let captured_request = captured
+ .lock()
+ .expect("captured request lock should not poison")
+ .clone()
+ .expect("upstream request should be captured");
+ let head = captured_request.to_ascii_lowercase();
+ assert!(
+ head.starts_with("post /v1/messages"),
+ "upstream must receive the Anthropic path: {captured_request}"
+ );
+ assert!(
+ head.contains("x-api-key: router-key"),
+ "upstream must receive the account Router key: {captured_request}"
+ );
+ assert!(
+ !head.contains(&token.to_ascii_lowercase()),
+ "platform access token must never reach the Router"
+ );
+ }
+
fn spawn_mock_server(responses: Vec) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs
index d457b795e..4cfbda348 100644
--- a/server-rs/crates/api-server/src/modules/platform.rs
+++ b/server-rs/crates/api-server/src/modules/platform.rs
@@ -9,7 +9,7 @@ use crate::{
auth::require_bearer_auth,
llm::{
LLM_REQUEST_MAX_BODY_BYTES, list_llm_models, proxy_llm_chat_completions,
- proxy_llm_responses,
+ proxy_llm_messages, proxy_llm_responses,
},
state::AppState,
volcengine_speech::{
@@ -36,6 +36,15 @@ pub fn router(state: AppState) -> Router {
))
.layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
)
+ .route(
+ "/api/llm/v1/chat/completions",
+ post(proxy_llm_chat_completions)
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ ))
+ .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
+ )
.route(
"/api/llm/responses",
post(proxy_llm_responses)
@@ -45,6 +54,27 @@ pub fn router(state: AppState) -> Router {
))
.layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
)
+ .route(
+ "/api/llm/v1/responses",
+ post(proxy_llm_responses)
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ ))
+ .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
+ )
+ // Anthropic 客户端(Claude Agent SDK / Claude Code CLI)固定请求
+ // `{ANTHROPIC_BASE_URL}/v1/messages`,所以这里只声明 Anthropic 自己的
+ // 路由前缀,尾段交给上游协议本身,网关不自己发明版本段。
+ .route(
+ "/api/llm/anthropic/{*path}",
+ post(proxy_llm_messages)
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ ))
+ .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
+ )
.route(
"/api/speech/volcengine/config",
get(get_volcengine_speech_config).route_layer(middleware::from_fn_with_state(
diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs
index 9a02ec3ea..ee48803b9 100644
--- a/server-rs/crates/api-server/src/tracking.rs
+++ b/server-rs/crates/api-server/src/tracking.rs
@@ -602,6 +602,15 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(route_spec("llm_request", "llm", User, "anonymous")),
+ ("POST", "/api/llm/v1/responses") => {
+ Some(route_spec("llm_request", "llm", User, "anonymous"))
+ }
+ ("POST", "/api/llm/v1/chat/completions") => {
+ Some(route_spec("llm_request", "llm", User, "anonymous"))
+ }
+ ("POST", "/api/llm/anthropic/v1/messages") => {
+ Some(route_spec("llm_request", "llm", User, "anonymous"))
+ }
("GET", "/api/speech/volcengine/config") => Some(route_spec(
"speech_config_view",
"speech",
@@ -946,6 +955,7 @@ fn is_known_static_route_segment(segment: &str) -> bool {
matches!(
segment,
"ai" | "analytics"
+ | "anthropic"
| "api"
| "api-keys"
| "asr"
@@ -988,11 +998,13 @@ fn is_known_static_route_segment(segment: &str) -> bool {
| "library"
| "llm"
| "login"
+ | "messages"
| "optimizations"
| "login-options"
| "logout"
| "logout-all"
| "me"
+ | "responses"
| "metric"
| "objects"
| "orders"
@@ -1616,6 +1628,35 @@ mod tests {
);
}
+ #[test]
+ fn llm_gateway_protocol_paths_keep_their_tracking_specs() {
+ // 协议路径段必须保持静态,否则归一化会把 Anthropic 路由打成
+ // `/api/llm/{id}/v1/{id}`,埋点永远匹配不上。
+ assert_eq!(
+ normalize_route_path("/api/llm/anthropic/v1/messages"),
+ "/api/llm/anthropic/v1/messages"
+ );
+ assert_eq!(
+ normalize_route_path("/api/llm/v1/responses"),
+ "/api/llm/v1/responses"
+ );
+ assert_eq!(
+ normalize_route_path("/api/llm/v1/chat/completions"),
+ "/api/llm/v1/chat/completions"
+ );
+
+ for path in [
+ "/api/llm/v1/responses",
+ "/api/llm/v1/chat/completions",
+ "/api/llm/anthropic/v1/messages",
+ ] {
+ let spec = resolve_route_tracking_spec(&Method::POST, path)
+ .unwrap_or_else(|| panic!("{path} should keep the llm_request spec"));
+ assert_eq!(spec.event_key, "llm_request");
+ assert_eq!(spec.module_key, "llm");
+ }
+ }
+
#[test]
fn active_runtime_settings_routes_keep_tracking_specs() {
for (method, event_key) in [
diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs
index 16cb030a8..e4ff1c5fb 100644
--- a/server-rs/crates/module-runtime/src/agc_models.rs
+++ b/server-rs/crates/module-runtime/src/agc_models.rs
@@ -17,6 +17,9 @@ pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32;
pub enum AgcAgentMode {
Codex,
Cc,
+ /// 显式 Anthropic Messages 协议。`cc` 是同一执行器的历史别名,
+ /// 新目录项应直接写 `anthropic`。
+ Anthropic,
}
impl Default for AgcAgentMode {
@@ -30,6 +33,7 @@ impl AgcAgentMode {
match self {
Self::Codex => "codex",
Self::Cc => "cc",
+ Self::Anthropic => "anthropic",
}
}
@@ -37,7 +41,8 @@ impl AgcAgentMode {
match value {
"codex" => Ok(Self::Codex),
"cc" => Ok(Self::Cc),
- _ => Err("Agent 模式只允许 codex 或 cc".into()),
+ "anthropic" => Ok(Self::Anthropic),
+ _ => Err("Agent 模式只允许 codex、cc 或 anthropic".into()),
}
}
}
@@ -340,13 +345,21 @@ mod tests {
}
#[test]
- fn catalog_agent_mode_accepts_codex_and_cc_only() {
+ fn catalog_agent_mode_accepts_codex_cc_and_anthropic_only() {
let mut catalog =
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
catalog.models[0].agent_mode = AgcAgentMode::Cc;
assert!(catalog.validate().is_ok());
assert_eq!(catalog.models[0].agent_mode.as_str(), "cc");
+ catalog.models[0].agent_mode = AgcAgentMode::Anthropic;
+ assert!(catalog.validate().is_ok());
+ assert_eq!(catalog.models[0].agent_mode.as_str(), "anthropic");
+ assert_eq!(
+ AgcAgentMode::parse("anthropic").unwrap(),
+ AgcAgentMode::Anthropic
+ );
+
let invalid: Result = serde_json::from_str(
r#"{"revision":0,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"provider"}]}"#,
);
diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx
index 080996f2d..acf9d4934 100644
--- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx
+++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx
@@ -18,6 +18,7 @@ const callbacks = {
onOpenApiKeys: vi.fn(),
onOpenCommunity: vi.fn(),
onOpenFeedback: vi.fn(),
+ onOpenInvite: vi.fn(),
onOpenRecharge: vi.fn(),
onOpenRewardCode: vi.fn(),
onOpenSettings: vi.fn(),
@@ -90,6 +91,7 @@ describe('PlatformActiveProfileView', () => {
expect(screen.getByText(/陶泥号:\s*100001/u)).toBeTruthy();
expect(screen.getByRole('button', { name: '泥点余额 108' })).toBeTruthy();
expect(screen.getByRole('button', { name: /泥点充值/u })).toBeTruthy();
+ expect(screen.getByRole('button', { name: /邀请好友/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /兑换码/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /玩家社区/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /反馈与建议/u })).toBeTruthy();
@@ -98,6 +100,9 @@ describe('PlatformActiveProfileView', () => {
screen.getByRole('button', { name: /开发者 API Key/u }),
).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: /邀请好友/u }));
+ expect(callbacks.onOpenInvite).toHaveBeenCalledTimes(1);
+
fireEvent.click(screen.getByRole('button', { name: '用户协议' }));
const legalDialog = screen.getByRole('dialog', { name: '用户协议' });
expect(legalDialog.parentElement?.className).toContain(
diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx
index 8c5c39e74..8b431a2aa 100644
--- a/src/components/platform-entry/PlatformActiveProfileView.tsx
+++ b/src/components/platform-entry/PlatformActiveProfileView.tsx
@@ -7,6 +7,7 @@ import {
Pencil,
Settings,
Ticket,
+ UserPlus,
UserRound,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
@@ -21,6 +22,7 @@ import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
+import profileInviteImage from '../../../media/profile/_Image (5).png';
import profileGiftImage from '../../../media/profile/_Image (6).png';
import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
@@ -69,6 +71,7 @@ type PlatformActiveProfileViewProps = {
onOpenApiKeys: () => void;
onOpenCommunity: () => void;
onOpenFeedback: () => void;
+ onOpenInvite: () => void;
onOpenRecharge: () => void;
onOpenRewardCode: () => void;
onOpenSettings: () => void;
@@ -278,6 +281,7 @@ export function PlatformActiveProfileView({
onOpenApiKeys,
onOpenCommunity,
onOpenFeedback,
+ onOpenInvite,
onOpenRecharge,
onOpenRewardCode,
onOpenSettings,
@@ -606,7 +610,7 @@ export function PlatformActiveProfileView({
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
-
+
+
{
+ void readNativeHostClipboardText().then((text) => {
+ if (text) {
+ profileCenter.setReferralRedeemCode(text);
+ }
+ });
+ }, [profileCenter]);
+
const isMobileToolStage =
!isDesktopLayout &&
(selectionStage === 'creation-home' ||
@@ -766,6 +784,9 @@ export function PlatformEntryFlowShellImpl({
profileCenter.openProfilePopupPanel('community')
}
onOpenFeedback={openFeedback}
+ onOpenInvite={() =>
+ profileCenter.openProfilePopupPanel('invite')
+ }
onOpenRecharge={openRecharge}
onOpenRewardCode={profileCenter.openRewardCodeModal}
onOpenSettings={() => authUi?.openSettingsModal()}
@@ -870,14 +891,19 @@ export function PlatformEntryFlowShellImpl({
diff --git a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx b/src/components/platform-entry/PlatformProfileReferralModal.test.tsx
deleted file mode 100644
index d674c2b88..000000000
--- a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-/* @vitest-environment jsdom */
-
-import { render, screen, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { afterEach, describe, expect, test, vi } from 'vitest';
-
-import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
-import {
- canPasteFromNativeHostClipboard,
- readNativeHostClipboardText,
-} from './platformProfileHostClipboard';
-import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
-
-vi.mock('./platformProfileHostClipboard', () => ({
- canPasteFromNativeHostClipboard: vi.fn(() => false),
- readNativeHostClipboardText: vi.fn(),
-}));
-
-const canPasteFromNativeHostClipboardMock = vi.mocked(
- canPasteFromNativeHostClipboard,
-);
-const readNativeHostClipboardTextMock = vi.mocked(readNativeHostClipboardText);
-
-function buildCenter(
- overrides: Partial = {},
-): ProfileReferralInviteCenterResponse {
- return {
- inviteCode: 'ABCD1234',
- inviteLinkPath: '/invite/ABCD1234',
- invitedCount: 1,
- rewardedInviteCount: 1,
- todayInviterRewardCount: 1,
- todayInviterRewardRemaining: 9,
- rewardPoints: 66,
- invitedUsers: [
- {
- userId: 'user-2',
- displayName: '海盐',
- avatarUrl: null,
- boundAt: '2026-06-10T08:00:00.000Z',
- },
- ],
- hasRedeemedCode: false,
- boundInviterUserId: null,
- boundAt: null,
- updatedAt: '2026-06-10T08:00:00.000Z',
- ...overrides,
- };
-}
-
-describe('PlatformProfileReferralModal', () => {
- afterEach(() => {
- vi.clearAllMocks();
- canPasteFromNativeHostClipboardMock.mockReturnValue(false);
- });
-
- test('renders invite panel with shared profile content', () => {
- render(
- ,
- );
-
- const dialog = screen.getByRole('dialog', { name: '邀请好友' });
-
- expect(within(dialog).getByText('邀请码')).toBeTruthy();
- expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
- expect(within(dialog).getByText('海盐')).toBeTruthy();
- expect(within(dialog).getByText('成功邀请')).toBeTruthy();
- expect(
- within(dialog).getByRole('button', { name: /复制邀请/u }),
- ).toBeTruthy();
- });
-
- test('submits redeem panel with the shared form shell', async () => {
- const user = userEvent.setup();
- const onRedeemCodeChange = vi.fn();
- const onSubmitRedeemCode = vi.fn();
-
- render(
- ,
- );
-
- const dialog = screen.getByRole('dialog', { name: '填邀请码' });
- const input = within(dialog).getByRole('textbox', { name: '邀请码' });
-
- await user.type(input, ' c');
- expect(onRedeemCodeChange).toHaveBeenCalled();
-
- await user.click(within(dialog).getByRole('button', { name: '提交' }));
- expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
- });
-
- test('redeem panel can paste invite code from native host clipboard', async () => {
- const user = userEvent.setup();
- const onRedeemCodeChange = vi.fn();
- canPasteFromNativeHostClipboardMock.mockReturnValue(true);
- readNativeHostClipboardTextMock.mockResolvedValue('spring-2026');
-
- render(
- ,
- );
-
- await user.click(screen.getByRole('button', { name: '粘贴' }));
-
- expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1);
- expect(onRedeemCodeChange).toHaveBeenCalledWith('spring-2026');
- });
-
- test('renders community QR panels', () => {
- render(
- ,
- );
-
- const dialog = screen.getByRole('dialog', { name: '玩家社区' });
-
- expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
- expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
- expect(within(dialog).getByText('微信群')).toBeTruthy();
- expect(within(dialog).getByText('QQ群')).toBeTruthy();
- });
-});
diff --git a/src/components/platform-entry/PlatformProfileReferralModal.tsx b/src/components/platform-entry/PlatformProfileReferralModal.tsx
deleted file mode 100644
index 0f5151148..000000000
--- a/src/components/platform-entry/PlatformProfileReferralModal.tsx
+++ /dev/null
@@ -1,343 +0,0 @@
-import { ClipboardPaste, Copy } from 'lucide-react';
-import type { ReactNode } from 'react';
-
-import communityQqQrImage from '../../../media/social-media-group/qq.png';
-import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
-import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
-import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
-import { PlatformActionButton } from '../common/PlatformActionButton';
-import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
-import { PlatformEmptyState } from '../common/PlatformEmptyState';
-import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
-import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
-import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
-import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
-import { PlatformSubpanel } from '../common/PlatformSubpanel';
-import { PlatformTextField } from '../common/PlatformTextField';
-import type { CopyFeedbackState } from '../common/useCopyFeedback';
-import {
- canPasteFromNativeHostClipboard,
- readNativeHostClipboardText,
-} from './platformProfileHostClipboard';
-import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
-import type { ProfileReferralPanel } from './usePlatformProfileCenterController';
-
-type PlatformProfileReferralModalProps = {
- panel: ProfileReferralPanel;
- center: ProfileReferralInviteCenterResponse | null;
- isLoading: boolean;
- isSubmittingRedeem: boolean;
- redeemCode: string;
- copyInviteState: CopyFeedbackState;
- error: string | null;
- success: string | null;
- onClose: () => void;
- onCopyInvite: () => void;
- onRedeemCodeChange: (value: string) => void;
- onSubmitRedeemCode: () => void;
-};
-
-const COMMUNITY_QR_CODES = [
- {
- label: '微信群',
- src: communityWechatQrImage,
- alt: '玩家社区微信群二维码',
- },
- {
- label: 'QQ群',
- src: communityQqQrImage,
- alt: '玩家社区 QQ 群二维码',
- },
-] as const;
-
-function ProfileReferralUserAvatar({
- name,
- avatarUrl,
-}: {
- name: string;
- avatarUrl: string | null;
-}) {
- const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
-
- return (
-
- {avatarUrl ? (
-
- ) : (
- avatarLabel
- )}
-
- );
-}
-
-function resolvePanelTitle(panel: ProfileReferralPanel) {
- if (panel === 'invite') {
- return '邀请好友';
- }
- if (panel === 'redeem') {
- return '填邀请码';
- }
- return '玩家社区';
-}
-
-/**
- * 个人中心邀请能力统一弹层。
- * 承接邀请码、填码和社区二维码三种 profile panel,避免首页继续内联重复白底浮层实现。
- */
-export function PlatformProfileReferralModal({
- panel,
- center,
- isLoading,
- isSubmittingRedeem,
- redeemCode,
- copyInviteState,
- error,
- success,
- onClose,
- onCopyInvite,
- onRedeemCodeChange,
- onSubmitRedeemCode,
-}: PlatformProfileReferralModalProps) {
- const title = resolvePanelTitle(panel);
- const normalizedRedeemCode = redeemCode
- .trim()
- .replace(/[^0-9a-z]/gi, '')
- .toUpperCase();
- const canPasteRedeemCode = canPasteFromNativeHostClipboard();
- const pasteRedeemCode = () => {
- void readNativeHostClipboardText().then((text) => {
- if (text) {
- onRedeemCodeChange(text);
- }
- });
- };
-
- let content: ReactNode;
-
- if (panel === 'community') {
- content = (
-
- {COMMUNITY_QR_CODES.map((qrCode) => (
-
-
- 
-
-
- {qrCode.label}
-
-
- ))}
-
- );
- } else if (panel === 'redeem') {
- content = (
-
- }
- isEmpty={Boolean(center?.hasRedeemedCode)}
- emptyState={
-
- 已填写邀请码
-
- }
- >
-
-
- );
- } else {
- content = (
-
- }
- >
-
-
-
- 邀请码
-
-
- {center?.inviteCode ?? '--------'}
-
-
-
-
- {`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
-
- 每日最多获得十次邀请奖励。
-
- }
- actionSurface="profile"
- actionSize="md"
- actionFullWidth
- className="gap-2 rounded-xl"
- />
-
-
- 成功邀请
-
- {center?.invitedUsers?.length ? (
-
- {center.invitedUsers.map((user) => (
-
-
-
-
- {user.displayName || '玩家'}
-
-
-
- ))}
-
- ) : (
-
- 暂无成功邀请
-
- )}
-
-
-
- );
- }
-
- return (
-
- {title}
- {content}
-
- {error ? (
-
- {error}
-
- ) : null}
- {success ? (
-
- {success}
-
- ) : null}
-
- );
-}
diff --git a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx
index dbbb10029..2ec27ed59 100644
--- a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx
+++ b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx
@@ -11,11 +11,62 @@ import {
userB,
} from './usePlatformProfileCenterController.testSupport';
+const clipboardMock = vi.hoisted(() => ({
+ copyTextToClipboard: vi.fn(async () => true),
+}));
+
+vi.mock('../../services/clipboard', () => clipboardMock);
+
describe('usePlatformProfileCenterController redemption lifecycle', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/profile');
usePlatformWalletStore.getState().resetWalletBalance();
vi.clearAllMocks();
+ clipboardMock.copyTextToClipboard.mockResolvedValue(true);
+ });
+
+ test('copies the invite link and the invite code as separate actions', async () => {
+ profileClientMocks.getPlatformProfileReferralInviteCenter.mockResolvedValue(
+ {
+ inviteCode: 'USER-A',
+ inviteLinkPath: '/?inviteCode=USER-A',
+ invitedCount: 0,
+ rewardedInviteCount: 0,
+ todayInviterRewardCount: 0,
+ todayInviterRewardRemaining: 10,
+ rewardPoints: 30,
+ invitedUsers: [],
+ hasRedeemedCode: false,
+ boundInviterUserId: null,
+ boundAt: null,
+ updatedAt: '2026-10-01T00:00:00.000Z',
+ },
+ );
+ const expectedInviteLink = `${window.location.origin}/?inviteCode=USER-A`;
+ const { result } = renderController(userA);
+
+ await act(async () => {
+ result.current.openProfilePopupPanel('invite');
+ });
+
+ expect(result.current.profilePopupPanel).toBe('invite');
+ expect(result.current.inviteLinkUrl).toBe(expectedInviteLink);
+
+ await act(async () => {
+ result.current.copyInviteLink();
+ });
+ expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
+ expectedInviteLink,
+ );
+ expect(result.current.referralSuccess).toBe('邀请链接已复制');
+
+ await act(async () => {
+ result.current.copyInviteCode();
+ });
+ expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
+ 'USER-A',
+ );
+ expect(result.current.referralSuccess).toBe('邀请码已复制');
});
test('ignores a reward-code response after the account changes', async () => {
diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts
index 03a5f423d..208eaccd7 100644
--- a/src/components/platform-entry/usePlatformProfileCenterController.ts
+++ b/src/components/platform-entry/usePlatformProfileCenterController.ts
@@ -422,7 +422,9 @@ export function usePlatformProfileCenterController({
useState(false);
const [referralError, setReferralError] = useState(null);
const [referralSuccess, setReferralSuccess] = useState(null);
- const { copyState: inviteCopyState, copyText: copyInviteText } =
+ const { copyState: inviteLinkCopyState, copyText: copyInviteLinkText } =
+ useCopyFeedback();
+ const { copyState: inviteCodeCopyState, copyText: copyInviteCodeText } =
useCopyFeedback();
const pendingWechatRechargeOrderRef =
useRef(null);
@@ -1701,21 +1703,43 @@ export function usePlatformProfileCenterController({
setProfilePopupPanel(null);
}, []);
- const copyInviteInfo = useCallback(() => {
- if (!referralCenter?.inviteCode) {
+ /**
+ * 邀请链接是分享给好友的正式入口(好友打开后自动带上邀请码),
+ * 因此这里只用站点 origin 补全后端返回的相对路径。
+ */
+ const inviteLinkUrl = useMemo(() => {
+ const inviteLinkPath = referralCenter?.inviteLinkPath;
+ if (!inviteLinkPath) {
+ return null;
+ }
+ if (typeof window === 'undefined') {
+ return inviteLinkPath;
+ }
+ try {
+ return new URL(inviteLinkPath, window.location.origin).href;
+ } catch {
+ return inviteLinkPath;
+ }
+ }, [referralCenter]);
+
+ const copyInviteLink = useCallback(() => {
+ if (!inviteLinkUrl) {
return;
}
+ void copyInviteLinkText(inviteLinkUrl).then((copied) => {
+ setReferralSuccess(copied ? '邀请链接已复制' : '复制失败');
+ });
+ }, [copyInviteLinkText, inviteLinkUrl]);
- const inviteUrl =
- typeof window === 'undefined'
- ? referralCenter.inviteLinkPath
- : new URL(referralCenter.inviteLinkPath, window.location.origin).href;
- void copyInviteText(`${referralCenter.inviteCode} ${inviteUrl}`).then(
- (copied) => {
- setReferralSuccess(copied ? '已复制' : '复制失败');
- },
- );
- }, [copyInviteText, referralCenter]);
+ const copyInviteCode = useCallback(() => {
+ const inviteCode = referralCenter?.inviteCode;
+ if (!inviteCode) {
+ return;
+ }
+ void copyInviteCodeText(inviteCode).then((copied) => {
+ setReferralSuccess(copied ? '邀请码已复制' : '复制失败');
+ });
+ }, [copyInviteCodeText, referralCenter]);
const submitReferralRedeemCode = useCallback(() => {
const inviteCode = referralRedeemCode
@@ -1850,7 +1874,10 @@ export function usePlatformProfileCenterController({
closeNativeWechatPayment,
closeProfilePopupPanel,
confirmNativeWechatPayment,
- inviteCopyState: inviteCopyState as CopyFeedbackState,
+ copyInviteCode,
+ inviteCodeCopyState: inviteCodeCopyState as CopyFeedbackState,
+ inviteLinkCopyState: inviteLinkCopyState as CopyFeedbackState,
+ inviteLinkUrl,
isLoadingRechargeCenter,
isLoadingReferral,
isLoadingWalletLedger,
@@ -1892,6 +1919,6 @@ export function usePlatformProfileCenterController({
walletLedgerError,
wechatRechargeOrderConfirmationState,
buyRechargeProduct,
- copyInviteInfo,
+ copyInviteLink,
};
}
diff --git a/vite.config.ts b/vite.config.ts
index 1210009a3..142f05851 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -190,7 +190,6 @@ const ACTIVE_TAILWIND_SOURCES = `@import 'tailwindcss' source(none);
@source "./components/platform-entry/PlatformProfileApiKeysModal.tsx";
@source "./components/platform-entry/PlatformProfileModalShell.tsx";
@source "./components/platform-entry/PlatformProfileRechargeModal.tsx";
-@source "./components/platform-entry/PlatformProfileReferralModal.tsx";
@source "./components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx";
@source "./components/platform-entry/PlatformProfileWalletLedgerModal.tsx";
@source "./components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx";
|