AGC 渲染层网络与状态下沉 Rust,并修复画布交互与失败文案

- 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。
- 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。
- 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。
- 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。
This commit is contained in:
kdletters
2026-09-28 14:15:02 +08:00
parent 985df53a4e
commit 8701f43f75
80 changed files with 4735 additions and 6079 deletions
@@ -302,7 +302,7 @@ test('channel config keeps the client window contract across the Tauri config me
title: '陶泥儿 Release',
});
// 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效,
// 并让按 label 绑定的 capability(平台 HTTP 权限)不再命中。
// 并让按 label 绑定的 capability(剪贴板 / 原生对话框 / opener / updater 权限)不再命中。
assert.equal(clientWindow.label, 'client');
assert.equal(clientWindow.decorations, false);
assert.equal(clientWindow.width, 1280);
@@ -319,7 +319,16 @@ test('channel config keeps the client window contract across the Tauri config me
.map((name) =>
JSON.parse(readFileSync(new URL(name, capabilitiesDirectory), 'utf8')),
);
const httpCapability = capabilities.find((capability) =>
const clientCapability = capabilities.find((capability) =>
(capability.windows ?? []).includes(clientWindow.label),
);
assert.ok(
clientCapability,
`必须存在绑定 ${clientWindow.label} 窗口的 capability(剪贴板 / 原生对话框 / opener / updater 权限)`,
);
// 渲染层是离线前端:平台接口、OSS 直传、Provider 与更新清单的网络 IO 全部在 Rust 侧,
// 因此任何 capability 都不应再授予 http:default(历史上用于渲染层直传素材)。
const httpPermissionHolders = capabilities.filter((capability) =>
(capability.permissions ?? []).some(
(permission) =>
permission === 'http:default' ||
@@ -327,10 +336,10 @@ test('channel config keeps the client window contract across the Tauri config me
permission?.identifier === 'http:default'),
),
);
assert.ok(httpCapability, '客户端必须保留承载平台 HTTP 权限的 capability');
assert.ok(
(httpCapability.windows ?? []).includes(clientWindow.label),
`平台 HTTP capability 必须绑定 ${clientWindow.label} 窗口,实际:${httpCapability.windows}`,
assert.deepEqual(
httpPermissionHolders,
[],
'渲染层不得再被授予 http:default;平台与 OSS 的网络 IO 由 Rust facade 承担',
);
});
@@ -122,6 +122,24 @@ const rustSharedContractSource = fs.readFileSync(
);
const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
// React 不再把页面消息当作持久化事实;策划/Runtime 由 Rust coordinator 写入,
// 该通用命令仅保留给 Rust 内部链路与测试。
'append_local_conversation_message',
// 发行 facade 由 typed native service 通过注入的 invoke 调用;整包准备、旧分片上传
// 与资料命令仍保留给 Rust/旧 native 测试,不允许重新接回 React 直连 HTTP。
'generate_game_distribution_cover',
'prepare_local_project_game_package',
'read_game_cover_generation_price',
'read_game_publish_availability',
'suggest_game_distribution_publish_metadata',
'upload_local_project_game_package',
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
// 里以字面量出现,静态扫描仍按共享注册表核验。
'read_profile_recharge_center',
'read_profile_wallet_ledger',
'create_profile_recharge_order',
'confirm_wechat_profile_recharge_order',
'redeem_profile_reward_code',
// Supervisor 调试窗口、开发者面板与专业 Agent 对话的前端调用方已随 Supervisor
// 前端链路整体删除;命令本身仍注册在 Rust 侧供 native Runtime 与 Rust 测试使用,
// 保留 present,仅不出现在 App 前端源码里。
@@ -293,6 +293,7 @@ function assertShardsCoverEveryTest(testNames, shards) {
function runShard(executable, shardIndex, shardCount, shardTestNames) {
const label = `shard ${shardIndex + 1}/${shardCount}`;
const chunks = chunkTestNamesForArgumentLimit(shardTestNames);
const shardTmpDirectory = path.join(
options.shardTmpRoot,
`shard-${shardIndex + 1}`,
@@ -301,6 +302,76 @@ function runShard(executable, shardIndex, shardCount, shardTestNames) {
fs.mkdirSync(shardTmpDirectory, { recursive: true });
const startedAt = Date.now();
// 一片内的用例名会整串拼成 argv;Windows 的整条命令行上限约 32 KiB,734 个名字
// (约 30 KiB)正好把它顶爆(`spawn ENAMETOOLONG`)。这里只在超限时把该片拆成多次
// 「同一二进制 + 同一 TMPDIR」的运行并汇总结果,Linux 上短 argv 仍是单次运行。
return runShardChunks(
executable,
label,
shardTmpDirectory,
chunks,
startedAt,
);
}
function chunkTestNamesForArgumentLimit(testNames) {
const MAX_FILTER_ARGUMENT_CHARS = 12_000;
const chunks = [];
let current = [];
let currentLength = 0;
for (const testName of testNames) {
const cost = testName.length + 3;
if (
current.length > 0 &&
currentLength + cost > MAX_FILTER_ARGUMENT_CHARS
) {
chunks.push(current);
current = [];
currentLength = 0;
}
current.push(testName);
currentLength += cost;
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
async function runShardChunks(
executable,
label,
shardTmpDirectory,
chunks,
startedAt,
) {
const chunkResults = [];
for (const [index, chunk] of chunks.entries()) {
const chunkLabel =
chunks.length === 1
? label
: `${label} chunk ${index + 1}/${chunks.length}`;
chunkResults.push(
await runShardChunk(executable, chunkLabel, shardTmpDirectory, chunk),
);
}
return {
label,
ok: chunkResults.every((result) => result.ok),
durationMs: Date.now() - startedAt,
testCount: chunks.reduce((total, chunk) => total + chunk.length, 0),
failures: chunkResults.flatMap((result) => result.failures),
stderr: chunkResults
.map((result) => result.stderr)
.filter((value) => value.length > 0)
.join('\n'),
};
}
function runShardChunk(executable, label, shardTmpDirectory, shardTestNames) {
const startedAt = Date.now();
return new Promise((resolve) => {
const child = spawn(
executable,
+14 -218
View File
@@ -65,14 +65,13 @@ import {
taskRowsFromManifest,
} from './features/agent-runtime';
import {
isTransientProjectOpenMessage,
latestVisibleItems,
type ProjectChatComponentProps,
readInitialProjectPath,
type WorkspaceLauncherProps,
writeRecentWorkspace,
} from './features/app-shell/model';
import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher';
import { rememberRecentWorkspace } from './features/app-shell/workspacePreferences';
import { projectAgentRuntimeSummaries } from './features/project-summary/agentPresentation';
import {
isAbsoluteProjectPath,
@@ -105,7 +104,6 @@ import {
type ProjectAgentResultSummary,
type ProjectAgentRuntimeSummary,
} from './view/project-development';
import { DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX } from './view/project-development/chat/conversation/directCodexConversation';
import {
directCodexAttachmentContentParts,
toDirectCodexTurnAttachments,
@@ -119,25 +117,6 @@ import { PlanningChatView } from './view/project-development/planning/PlanningCh
import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation';
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
if (!message.runtimeOwned) {
return false;
}
const messageId = message.messageId?.trim() ?? '';
const roleSuffix = `:${message.role}`;
if (
!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) ||
!messageId.endsWith(roleSuffix)
) {
return false;
}
const turnId = messageId.slice(
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
-roleSuffix.length,
);
return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId);
}
/**
* 历史回读与"尚未落盘的运行时消息"合并。
*
@@ -530,7 +509,7 @@ export function App({
return messages;
}
function applyDesignView(view: DesignView, projectPath: string) {
function applyDesignView(view: DesignView) {
designAgentLaneRef.current = true;
setDesignAgentView(view);
designAgentActiveRef.current = true;
@@ -539,8 +518,6 @@ export function App({
setChatAgentBusy(view.running);
const conversation = designMessagesToChat(view);
setMessages(conversation);
savedConversationProjectPathRef.current = projectPath;
savedConversationCountRef.current = conversation.length;
latestMessagesRef.current = conversation;
}
@@ -551,7 +528,7 @@ export function App({
) {
if (!isCurrentDesignTurn(projectPath, clientTurnId)) return;
designReplyAnimation.receiveView(view);
applyDesignView(view, projectPath);
applyDesignView(view);
setDesignAgentReasoning('');
if (!view.running) setDesignAgentStatus('');
}
@@ -571,7 +548,7 @@ export function App({
if (!view) {
return null;
}
applyDesignView(view, nextProjectPath);
applyDesignView(view);
return view;
}
@@ -642,22 +619,17 @@ export function App({
projectPath: string;
announceToChat: boolean;
} | null>(null);
const [conversationWriteVersion, setConversationWriteVersion] = useState(0);
const savedConversationCountRef = useRef(
eagerProject ? createDefaultChatMessages().length : 0,
);
const savedConversationProjectPathRef = useRef<string | null>(
eagerProject ? initialProjectPath : null,
);
const projectConversationWriteConfirmedRef = useRef<string | null>(null);
const projectConversationWriteCancelledRef = useRef<{
projectPath: string;
messageCount: number;
} | null>(null);
const latestMessagesRef = useRef<ChatMessage[]>([]);
const conversationWriteInFlightRef = useRef(false);
// 策划对话和 DirectProject 对话都由 Rust 持有正式历史;这里的消息只服务当前页面投影。
latestMessagesRef.current = messages;
const projectScopeVersionRef = useRef(0);
useEffect(() => {
// DirectProject 的权限门按项目作用域缓存;切换项目后必须重新向 Rust 询问。
projectConversationWriteConfirmedRef.current = null;
}, [localProject?.projectPath]);
const refreshManifest = useCallback(
(nextProjectPath = localProjectPathRef.current ?? ''): Promise<void> => {
const invoke = resolveTauriInvoke();
@@ -809,166 +781,6 @@ export function App({
chatFileImportNotice,
]);
useEffect(() => {
latestMessagesRef.current = messages;
const invoke = resolveTauriInvoke();
const nextProjectPath = localProject?.projectPath;
if (!invoke || !nextProjectPath) {
return;
}
if (savedConversationProjectPathRef.current !== nextProjectPath) {
savedConversationProjectPathRef.current = nextProjectPath;
savedConversationCountRef.current = 0;
projectConversationWriteConfirmedRef.current = null;
projectConversationWriteCancelledRef.current = null;
}
// DirectProject history is written by Rust from raw app-server items.
// The browser only renders that projection and must not append chat rows.
if (directProjectMode) {
// `messages` is only an optimistic UI projection in Direct mode; it is
// intentionally not proof of durability. Rust owns the raw response
// history, so this effect must not route these rows through the generic
// browser conversation writer.
savedConversationCountRef.current = messages.length;
return;
}
const start = savedConversationCountRef.current;
const pendingMessages = messages.slice(start);
if (pendingMessages.length === 0) {
return;
}
if (
pendingMessages.every(
(message) =>
(message.runtimeOwned &&
!isPersistableDirectCodexConversationMessage(message)) ||
isTransientProjectOpenMessage(message, nextProjectPath),
)
) {
savedConversationCountRef.current = messages.length;
return;
}
if (conversationWriteInFlightRef.current) {
return;
}
if (pendingUiConfirmation) {
return;
}
if (
projectConversationWriteCancelledRef.current?.projectPath ===
nextProjectPath &&
projectConversationWriteCancelledRef.current.messageCount ===
messages.length
) {
return;
}
if (
projectConversationWriteCancelledRef.current?.projectPath ===
nextProjectPath &&
projectConversationWriteCancelledRef.current.messageCount <
messages.length
) {
projectConversationWriteCancelledRef.current = null;
}
if (projectConversationWriteConfirmedRef.current !== nextProjectPath) {
void invoke<ProjectPermissionPolicyView>(
'read_project_permission_policy',
{ projectPath: nextProjectPath },
)
.then((policyView) => {
if (
!policyView.policy.confirmCommands.includes('conversation.write')
) {
projectConversationWriteConfirmedRef.current = nextProjectPath;
setConversationWriteVersion((current) => current + 1);
return;
}
requestProjectPolicyConfirmation(
'conversation.write',
nextProjectPath,
`写入 ${nextProjectPath} 的项目对话`,
() => {
projectConversationWriteConfirmedRef.current = nextProjectPath;
setConversationWriteVersion((current) => current + 1);
},
);
setWorkspaceStatus('等待确认保存项目对话');
})
.catch(() => {
projectConversationWriteConfirmedRef.current = nextProjectPath;
setConversationWriteVersion((current) => current + 1);
});
return;
}
conversationWriteInFlightRef.current = true;
void (async () => {
for (const [index, message] of pendingMessages.entries()) {
if (
message.runtimeOwned &&
!isPersistableDirectCodexConversationMessage(message)
) {
savedConversationCountRef.current = start + index + 1;
continue;
}
if (isTransientProjectOpenMessage(message, nextProjectPath)) {
savedConversationCountRef.current = start + index + 1;
continue;
}
await invoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: nextProjectPath,
agentId: null,
...(message.messageId ? { messageId: message.messageId } : {}),
message: {
role: message.role === 'user' ? 'user' : 'assistant',
content: message.text,
agentId: null,
...(typeof message.updatedAt === 'number'
? { updatedAt: message.updatedAt }
: {}),
},
},
);
savedConversationCountRef.current = start + index + 1;
}
setWorkspaceStatus((current) =>
current.startsWith('项目对话保存失败') ||
current === '等待确认保存项目对话'
? `已打开:${nextProjectPath}`
: current,
);
})()
.catch((error) => {
savedConversationCountRef.current = Math.min(
savedConversationCountRef.current,
start,
);
setWorkspaceStatus(
`项目对话保存失败:${
error instanceof Error ? error.message : String(error)
}`,
);
})
.finally(() => {
conversationWriteInFlightRef.current = false;
if (
savedConversationCountRef.current < latestMessagesRef.current.length
) {
setConversationWriteVersion((current) => current + 1);
}
});
// Writes are driven by message and confirmation state; the confirmation helper
// intentionally observes the latest policy refs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
localProject?.projectPath,
messages,
conversationWriteVersion,
pendingUiConfirmation,
directProjectMode,
]);
function appendLocalPermissionLog(
projectPath: string | null,
event:
@@ -1185,20 +997,6 @@ export function App({
if (!pending) {
return;
}
if (pending.commandId === 'conversation.write') {
const nextProjectPath = resolveChatProjectPath(localProject);
if (nextProjectPath) {
projectConversationWriteCancelledRef.current = {
projectPath: nextProjectPath,
messageCount: latestMessagesRef.current.length,
};
setWorkspaceStatus((current) =>
current === '等待确认保存项目对话'
? `已打开:${nextProjectPath}`
: current,
);
}
}
if (pending.commandId === 'conversation.read') {
if (!pending.detail.includes('Agent 对话')) {
setWorkspaceStatus((current) =>
@@ -1364,8 +1162,6 @@ export function App({
designAgentLaneRef.current = true;
setDesignAgentView(null);
setMessages(createDefaultChatMessages());
savedConversationProjectPathRef.current = nextProjectPath;
savedConversationCountRef.current = 0;
latestMessagesRef.current = [];
setWorkspaceStatus((workspaceStatus) =>
workspaceStatus === '等待确认'
@@ -1469,9 +1265,9 @@ export function App({
setAgentRuntimeById({});
setMessages(conversationMessages);
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
savedConversationProjectPathRef.current = openedProject.projectPath;
savedConversationCountRef.current = conversationMessages.length;
writeRecentWorkspace(openedProject.projectPath);
void rememberRecentWorkspace(openedProject.projectPath).catch(() => {
// 最近项目是辅助投影;Rust 建项/打开结果不因偏好写入失败而回滚。
});
setWorkspaceStatus(`已打开:${openedProject.projectPath}`);
appendLocalPermissionLog(
openedProject.projectPath,
@@ -12,16 +12,14 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice';
import {
clearStoredAuthAccessToken,
getClientAuthErrorMessage,
getCurrentClientAuthUser,
getStoredAuthAccessToken,
isClientAuthRecoverableCheckError,
loginClientWithPassword,
loginClientWithPhoneCode,
logoutClientAuthSession,
normalizeAuthPhoneInput,
readClientAuthState,
sendClientPhoneLoginCode,
subscribeClientAuthState,
} from '../services/clientAuth';
import {
type ClientServerPreset,
@@ -29,6 +27,7 @@ import {
clientServerSelectionEnabled,
getClientServerBaseUrl,
getClientServerSelection,
hydrateClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
@@ -40,12 +39,8 @@ import {
import {
beginPlatformSessionClearTransition,
beginPlatformSessionTransition,
clearCommittedPlatformSession,
commitAuthenticatedPlatformSession,
currentPlatformSessionApiBaseUrl,
currentPlatformSessionGeneration,
refreshPlatformSessionForGeneration,
subscribePlatformSessionRefresh,
} from '../services/platformSession';
type ClientRuntimeErrorBoundaryProps = {
@@ -193,6 +188,20 @@ export function AuthenticatedClient({
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
// 服务器选择是 Rust 客户端偏好:本地 debug 启动时读一次,打包产物始终用渠道默认值。
useEffect(() => {
if (!clientServerSelectionEnabled) return;
let disposed = false;
void hydrateClientServerSelection().then((selection) => {
if (disposed) return;
setServerSelection(selection);
setCustomServerUrl(selection.customBaseUrl);
});
return () => {
disposed = true;
};
}, []);
useEffect(() => {
const uninstallWebviewLogBridge = installWebviewLogBridge();
const handleError = (event: ErrorEvent) => {
@@ -212,166 +221,53 @@ export function AuthenticatedClient({
};
}, []);
/** 恢复登录态:凭据续期、用户复核与原生会话安装都在 Rust 内完成。 */
useEffect(() => {
let disposed = false;
async function hydrateAuth() {
const runId = ++authCheckRunRef.current;
const isActiveRun = () => !disposed && authCheckRunRef.current === runId;
const hydrationGeneration = currentPlatformSessionGeneration();
const hydrationApiBaseUrl = getClientServerBaseUrl();
setAuthCheckError('');
setAuthCheckStage('token');
setAuthCheckStage('refresh');
try {
if (!getStoredAuthAccessToken()) {
setAuthCheckStage('refresh');
const refreshed = await withAuthCheckTimeout(
refreshPlatformSessionForGeneration(
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'刷新登录状态超时,请检查服务器地址和网络后重试',
);
if (!refreshed) {
if (isActiveRun()) {
setAuthStatus('unauthenticated');
}
return;
}
}
if (!isActiveRun()) return;
setAuthCheckStage('me');
const user = await withAuthCheckTimeout(
getCurrentClientAuthUser(hydrationApiBaseUrl),
const state = await withAuthCheckTimeout(
readClientAuthState(getClientServerBaseUrl()),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'读取当前用户超时,请检查服务器地址和网络后重试',
'检查登录状态超时,请检查服务器地址和网络后重试',
);
if (!isActiveRun()) {
return;
}
if (user) {
setAuthCheckStage('runner');
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null) {
return;
}
if (!isActiveRun()) {
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
return;
}
clearStoredAuthAccessToken();
setAuthStatus('unauthenticated');
} catch (error) {
if (!isActiveRun()) {
return;
}
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(error)
) {
const message = getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
if (!isActiveRun()) return;
if (state.status === 'unavailable') {
setAuthCheckError(state.message);
setLoginStatus(state.message);
setAuthStatus('unauthenticated');
return;
}
if (getStoredAuthAccessToken()) {
try {
if (!isActiveRun()) return;
setAuthCheckStage('refresh');
const refreshed = await withAuthCheckTimeout(
refreshPlatformSessionForGeneration(
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'刷新登录状态超时,请检查服务器地址和网络后重试',
);
if (!refreshed) {
if (isActiveRun()) {
setAuthStatus('unauthenticated');
}
return;
}
if (!isActiveRun()) return;
setAuthCheckStage('me');
const user = await withAuthCheckTimeout(
getCurrentClientAuthUser(hydrationApiBaseUrl),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'读取当前用户超时,请检查服务器地址和网络后重试',
);
if (!isActiveRun()) {
return;
}
if (user) {
if (!isActiveRun()) return;
setAuthCheckStage('runner');
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null) {
return;
}
if (!isActiveRun()) {
return;
}
setAuthCheckError('');
setAuthUser(user);
setAuthStatus('authenticated');
return;
}
} catch (retryError) {
if (!isActiveRun()) {
return;
}
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(retryError)
) {
const message = getClientAuthErrorMessage(
retryError,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
setAuthStatus('unauthenticated');
return;
}
}
}
if (!isActiveRun()) {
if (state.status !== 'authenticated') {
setAuthStatus('unauthenticated');
return;
}
if (isClientAuthRecoverableCheckError(error)) {
const message = getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
}
clearStoredAuthAccessToken();
setAuthCheckStage('runner');
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
state.user,
currentPlatformSessionGeneration(),
state.apiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null || !isActiveRun()) return;
setAuthUser(state.user);
setAuthCheckError('');
setAuthStatus('authenticated');
} catch (error) {
if (!isActiveRun()) return;
const message = getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
setAuthStatus('unauthenticated');
}
}
@@ -380,7 +276,6 @@ export function AuthenticatedClient({
disposed = true;
};
}, [authCheckRetryKey]);
useEffect(() => {
if (authStatus !== 'checking') {
return;
@@ -392,26 +287,34 @@ export function AuthenticatedClient({
return () => window.clearInterval(timer);
}, [authStatus, authCheckRetryKey]);
useEffect(
() =>
subscribePlatformSessionRefresh((result) => {
if (result.status === 'refreshed') {
setAuthUser((current) =>
current?.id === result.user.id ? result.user : current,
);
return;
}
// 仅服务端明确否认当前身份时才登出。网络错误、5xx 和网关错误属于刷新暂时
// 不可用,必须保留既有会话与 access token。
if (result.status === 'failed' && result.authoritative) {
clearStoredAuthAccessToken();
setAuthUser(null);
setAuthStatus('unauthenticated');
setLoginStatus('登录已失效,请重新登录');
}
}),
[],
);
// Rust 是认证态事实源:另一个窗口登录/登出、或 Rust 内部判定登录失效时同步界面。
useEffect(() => {
let disposed = false;
let unsubscribe: (() => void) | null = null;
void subscribeClientAuthState((state) => {
if (disposed) return;
if (state.status === 'authenticated') {
setAuthUser((current) =>
current?.id === state.user.id ? state.user : state.user,
);
setAuthCheckError('');
setAuthStatus('authenticated');
return;
}
setAuthUser(null);
setAuthStatus('unauthenticated');
setLoginStatus('登录已失效,请重新登录');
})
.then((release) => {
if (disposed) release();
else unsubscribe = release;
})
.catch(() => undefined);
return () => {
disposed = true;
unsubscribe?.();
};
}, []);
useEffect(() => {
if (codeCooldownSeconds <= 0) {
@@ -480,6 +383,7 @@ export function AuthenticatedClient({
setLoginStatus('正在登录');
const loginGeneration = beginPlatformSessionTransition();
try {
// Rust 登录命令内部完成:凭据持久化 → 本机运行时会话安装 → Runner 传播 → 事件。
const user =
loginMode === 'code'
? await loginClientWithPhoneCode(
@@ -492,46 +396,21 @@ export function AuthenticatedClient({
password,
loginApiBaseUrl,
);
const commitRequest = commitAuthenticatedPlatformSession(
user,
loginGeneration,
loginApiBaseUrl,
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
loginGeneration,
loginApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
let commitFenceExpired = false;
// 围栏只放弃等待,不放弃结果:本地运行时确实装好会话时,界面必须跟着进工作区,
// 否则用户停在登录页、而后端已经认为登录成功(重试也会被已装的会话挡住)。
void commitRequest
.then((committedGeneration) => {
if (
committedGeneration === null ||
!commitFenceExpired ||
loginAttemptRef.current !== loginAttempt ||
currentPlatformSessionGeneration() !== committedGeneration
) {
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
setCode('');
setPassword('');
setLoginStatus('本地运行时登录态已确认');
})
.catch(() => undefined);
let committedGeneration: number | null;
try {
committedGeneration = await withAuthCheckTimeout(
commitRequest,
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
} catch (error) {
commitFenceExpired = true;
throw error;
}
if (committedGeneration === null) {
return;
}
if (loginAttemptRef.current !== loginAttempt) {
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
@@ -540,24 +419,17 @@ export function AuthenticatedClient({
} catch (error) {
if (shouldCaptureClientError(error))
void captureClientError(error, { source: 'auth', action: 'login' });
setLoginStatus(error instanceof Error ? error.message : String(error));
setLoginStatus(getClientAuthErrorMessage(error, '登录失败'));
} finally {
setLoginBusy(false);
}
}
async function logout() {
const logoutApiBaseUrl = currentPlatformSessionApiBaseUrl();
const logoutGeneration = beginPlatformSessionClearTransition();
beginPlatformSessionClearTransition();
let nativeClearError: unknown = null;
try {
await logoutClientAuthSession(logoutApiBaseUrl);
} catch {
clearStoredAuthAccessToken();
}
try {
await withAuthCheckTimeout(
clearCommittedPlatformSession(logoutGeneration),
logoutClientAuthSession(),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'清理本地运行时超时,请重启客户端后再登录',
);
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { useEffect, useRef, useState } from 'react';
import { SpriteImagePreview } from '../../features/ui-editor/components/SpriteImagePreview';
import { resolveClientAssetReadUrl } from '../../services/clientApi';
import { readEditorAssetPreview } from '../../services/editorAssetHost';
import {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
@@ -42,9 +42,9 @@ export function ImageImporterPreview({
setPreviewUrl(selected.previewUrl);
return;
}
if (selected.source === 'remote' && selected.remoteObjectKey) {
void resolveClientAssetReadUrl(selected.remoteObjectKey)
.then(setPreviewUrl)
if (selected.source === 'remote' && selected.remoteAssetId) {
void readEditorAssetPreview(selected.remoteAssetId)
.then((preview) => setPreviewUrl(preview.previewUrl))
.catch(() => setPreviewUrl(null));
return;
}
@@ -8,9 +8,9 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
loadEditorAssetLibrary,
resolveClientAssetReadUrl,
} from '../../services/clientApi';
readEditorAssetLibrary,
readEditorAssetPreview,
} from '../../services/editorAssetHost';
import { ThemedModal } from '../modal/ThemedModal';
import {
type AssetImporterSettings,
@@ -158,7 +158,7 @@ export function AssetImporter({
);
const loadRemote = useCallback(async () => {
const library = await loadEditorAssetLibrary();
const library = await readEditorAssetLibrary();
if (!settings.remote) return [];
const remoteFiles = buildRemoteFolderFiles(
library,
@@ -346,13 +346,11 @@ export function AssetImporter({
remoteAssets: await Promise.all(
remoteChosen.map(async (item) => {
const asset = item.asset!;
const downloadUrl = asset.localPath.startsWith('http')
? asset.localPath
: await resolveClientAssetReadUrl(asset.localPath);
const preview = await readEditorAssetPreview(asset.id);
return {
assetId: asset.id,
objectKey: asset.localPath,
downloadUrl,
downloadUrl: preview.previewUrl,
};
}),
),
@@ -93,6 +93,7 @@ export type ManagerFile = FileManagerFile & {
previewUrl?: string;
source?: 'local' | 'remote';
remoteObjectKey?: string;
remoteAssetId?: string;
asset?: ImportedAsset;
/** Project-relative source path for a local disk candidate. */
localProjectPath?: string;
@@ -351,12 +352,13 @@ export function buildRemoteFolderFiles(
? `${baseName} (${safeManagerName(asset.assetId, String(usedNames.size + 1))})`
: baseName;
usedNames.add(fileName);
const localPath =
asset.objectKey || asset.imageSrc || asset.assetId || fileName;
const assetId =
asset.assetId || asset.objectKey || asset.imageSrc || fileName;
const localPath = assetId;
const candidate: RemoteAssetCandidate = {
assetId: asset.assetId || asset.objectKey || asset.imageSrc || fileName,
assetId,
name: baseName,
objectKey: asset.objectKey || asset.imageSrc || fileName,
objectKey: asset.objectKey || assetId,
assetKind:
asset.assetKind == null
? null
@@ -375,13 +377,9 @@ export function buildRemoteFolderFiles(
size: asset.size,
previewUrl: asset.previewUrl,
source: 'remote',
remoteObjectKey: asset.objectKey,
remoteAssetId: assetId,
asset: {
id:
asset.assetId ||
asset.objectKey ||
asset.imageSrc ||
'platform-image',
id: assetId,
localPath,
assetKind: asset.assetKind == null ? null : candidate.assetKind,
},
@@ -5,7 +5,6 @@ import {
type ClientErrorEvent,
type DiagnosticLogFile,
getPendingClientErrorEvents,
getStableErrorReportSubmissionId,
readApplicationDiagnosticLogs,
submitErrorReportBatch,
} from '../../services/errorReporting';
@@ -108,12 +107,10 @@ export function ErrorReportDialog({
setBusy(true);
setStatus('正在提交…');
try {
const submissionId = getStableErrorReportSubmissionId(selectedEvents);
await submitErrorReportBatch({
events: selectedEvents,
logs: includeLogs ? logs : [],
userDescription: description,
submissionId,
});
setStatus('已提交,感谢你的反馈');
window.setTimeout(onClose, 700);
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types';
import {
canSubscribeTauriEvents,
subscribeTauriEvent,
} from '../../services/tauriEventSubscription';
/**
* 轮询间隔:注册表是进程内只读快照,一次查询只是一次 IPC + 一次内存遍历。
* "哪些项目正在跑"不值得再建一套事件流,而且轮询能在丢事件时自愈。
*/
export const DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS = 5_000;
export const DIRECT_ACTIVE_TURNS_CHANGED_EVENT =
'game-creator-direct-active-turns-changed';
/**
* 单次刷新里的读取尝试次数。快照读取失败最多重试 3 次,3 次全部失败才把
@@ -26,11 +27,9 @@ const DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS = 300;
export function useDirectActiveTurns({
invoke,
enabled,
pollIntervalMs = DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
}: {
invoke: TauriInvoke | null | undefined;
enabled: boolean;
pollIntervalMs?: number;
}) {
const [activeTurns, setActiveTurns] = useState<GameCreatorDirectActiveTurn[]>(
[],
@@ -48,6 +47,7 @@ export function useDirectActiveTurns({
const lastSnapshotSignatureRef = useRef<string>('[]');
const requestGenerationRef = useRef(0);
const retryTimerRef = useRef<number | null>(null);
const refreshPendingRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
@@ -64,10 +64,12 @@ export function useDirectActiveTurns({
if (!enabled || !invoke) {
return;
}
// 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。
// 单飞:通知与手动刷新不叠成两个在途请求;在途期间到达的通知只记一笔 dirty。
if (inFlightRef.current) {
refreshPendingRef.current = true;
return inFlightRef.current;
}
refreshPendingRef.current = false;
const generation = requestGenerationRef.current;
const isCurrent = () =>
mountedRef.current && generation === requestGenerationRef.current;
@@ -113,29 +115,62 @@ export function useDirectActiveTurns({
}
})();
inFlightRef.current = request;
void request.finally(() => {
if (!isCurrent() || !refreshPendingRef.current) {
return;
}
refreshPendingRef.current = false;
void refreshActiveTurns();
});
return request;
}, [enabled, invoke]);
useEffect(() => {
if (!enabled || !invoke) {
lastSnapshotSignatureRef.current = '[]';
refreshPendingRef.current = false;
// 空态也要保持引用稳定:已经空了就不要再换一个新数组。
setActiveTurns((current) => (current.length === 0 ? current : []));
setSnapshotReadFailed((current) => (current ? false : current));
return;
}
void refreshActiveTurns();
const timer = window.setInterval(
() => void refreshActiveTurns(),
Math.max(1_000, pollIntervalMs),
);
let disposed = false;
let unsubscribe: (() => void) | null = null;
const setup = async () => {
if (canSubscribeTauriEvents()) {
try {
const release = await subscribeTauriEvent<{ revision?: number }>(
DIRECT_ACTIVE_TURNS_CHANGED_EVENT,
() => {
if (!disposed) {
void refreshActiveTurns();
}
},
);
if (disposed) {
release();
return;
}
unsubscribe = release;
} catch {
// 事件桥不可用时仍做一次启动快照;不恢复定时轮询。
}
}
if (!disposed) {
// 首次读取必须发生在订阅之后,避免登记与快照之间的状态变化丢失。
await refreshActiveTurns();
}
};
void setup();
return () => {
window.clearInterval(timer);
disposed = true;
unsubscribe?.();
// 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。
requestGenerationRef.current += 1;
inFlightRef.current = null;
refreshPendingRef.current = false;
};
}, [enabled, invoke, pollIntervalMs, refreshActiveTurns]);
}, [enabled, invoke, refreshActiveTurns]);
return { activeTurns, refreshActiveTurns, snapshotReadFailed };
}
@@ -891,8 +891,15 @@ function redactDirectFailureMarkers(value: string) {
function directCodexDiagnosticFailureDetail(message: string) {
const trimmed = message.trim();
const match =
// v1 是历史记录里的形状。
/^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec(
trimmed,
) ??
// v2 是当前 Native 运行时实际下发的形状:在 v1 之上追加了 `code=`,并在末尾拼上
// 诊断引用(`;详情:.agent/runtime/errors/<eventId>.json`)。引用只服务「查看详情」
// 入口,不是用户可见文案,因此 `code=` 与引用都不进捕获组。
/^direct-codex-failure:v2 stage=(request|art-preparation|code-generation|browser-validation|version-registration) code=[a-z0-9-]+ retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)(?:;详情:\S+)?$/u.exec(
trimmed,
);
if (!match) {
return null;
@@ -23,11 +23,6 @@ import {
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
const RECENT_WORKSPACES_STORAGE_KEY =
'genarrative-ai-game-creator.recent-workspaces.v1';
const PROJECT_CREATION_DIRECTORY_STORAGE_KEY =
'genarrative-ai-game-creator.project-creation-directory.v1';
export type WorkspaceLauncherProps = {
currentUser: AuthUser;
onLogout: () => void;
@@ -107,7 +102,7 @@ export type LauncherNotice = {
message: string;
};
function normalizeRecentWorkspaceList(values: unknown[]) {
export function normalizeRecentWorkspaces(values: unknown[]) {
const recent: string[] = [];
for (const value of values) {
if (typeof value !== 'string') {
@@ -129,52 +124,6 @@ function normalizeRecentWorkspaceList(values: unknown[]) {
return recent;
}
export function readRecentWorkspaces() {
try {
const raw = window.localStorage.getItem(RECENT_WORKSPACES_STORAGE_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? normalizeRecentWorkspaceList(parsed) : [];
} catch {
return [];
}
}
export function writeRecentWorkspace(path: string) {
const recent = normalizeRecentWorkspaceList([
path,
...readRecentWorkspaces(),
]);
try {
window.localStorage.setItem(
RECENT_WORKSPACES_STORAGE_KEY,
JSON.stringify(recent),
);
} catch {
// WebView storage can be unavailable in restricted test shells.
}
return recent;
}
export function removeRecentWorkspace(path: string) {
const trimmedPath = path.trim();
const recent = readRecentWorkspaces().filter(
(workspace) => workspace !== trimmedPath,
);
try {
if (recent.length > 0) {
window.localStorage.setItem(
RECENT_WORKSPACES_STORAGE_KEY,
JSON.stringify(recent),
);
} else {
window.localStorage.removeItem(RECENT_WORKSPACES_STORAGE_KEY);
}
} catch {
// WebView storage can be unavailable in restricted test shells.
}
return recent;
}
/**
* 「项目创建目录」偏好:空串表示沿用 AGC 管理的默认位置(应用数据目录下的 projects)。
*
@@ -183,7 +132,11 @@ export function removeRecentWorkspace(path: string) {
*/
export function normalizeProjectCreationDirectory(value: string) {
const trimmed = value.trim();
if (!trimmed || projectPathHasControlCharacter(trimmed)) {
if (
!trimmed ||
!isAbsoluteProjectPath(trimmed) ||
projectPathHasControlCharacter(trimmed)
) {
return '';
}
const withoutTrailingSeparator = trimmed.replace(/[\\/]+$/, '');
@@ -193,39 +146,6 @@ export function normalizeProjectCreationDirectory(value: string) {
: withoutTrailingSeparator;
}
export function readProjectCreationDirectory() {
try {
const raw = window.localStorage.getItem(
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
);
const parsed: unknown = raw ? JSON.parse(raw) : '';
if (typeof parsed !== 'string') {
return '';
}
const directory = normalizeProjectCreationDirectory(parsed);
return isAbsoluteProjectPath(directory) ? directory : '';
} catch {
return '';
}
}
export function writeProjectCreationDirectory(path: string) {
const directory = normalizeProjectCreationDirectory(path);
try {
if (directory) {
window.localStorage.setItem(
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
JSON.stringify(directory),
);
} else {
window.localStorage.removeItem(PROJECT_CREATION_DIRECTORY_STORAGE_KEY);
}
} catch {
// WebView storage can be unavailable in restricted test shells.
}
return directory;
}
export function isTransientProjectOpenMessage(
message: ChatMessage,
projectPath: string,
@@ -13,7 +13,7 @@ import {
getClientProfileRechargeCenter,
getClientProfileWalletLedger,
redeemClientProfileRewardCode,
} from '../../services/clientApi';
} from '../../services/accountHost';
import { useWalletStore } from '../../stores/useWalletStore';
export function useAccountWallet(currentUserId: string) {
@@ -58,7 +58,6 @@ import {
ensureHomeWebCreationEnvironment,
HOME_WEB_PREFLIGHT_FAILURE,
} from './homeWebPreflight';
import { readProjectCreationDirectory } from './model';
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */
@@ -898,9 +897,6 @@ export function useHomeProjectCreation({
{
name: suggestedName,
planning: startMode === 'planning',
// 用户在首页选过「项目创建目录」就用它;没选传 null,由 Rust 侧回落到
// AGC 管理的默认位置(应用数据目录下的 projects)。
projectsRoot: readProjectCreationDirectory() || null,
},
);
createdProjectPath = result.projectPath;
@@ -1,10 +1,11 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import {
readProjectCreationDirectory,
writeProjectCreationDirectory,
} from './model';
readWorkspacePreferences,
setProjectCreationDirectory,
subscribeWorkspacePreferences,
} from './workspacePreferences';
/**
* 「项目创建目录」用户偏好。
@@ -14,14 +15,76 @@ import {
* 再由 Rust 侧私有路径门禁复核一次。
*/
export function useProjectCreationDirectory() {
const [projectCreationDirectory, setProjectCreationDirectory] = useState(
readProjectCreationDirectory,
);
const [projectCreationDirectory, setProjectCreationDirectoryState] =
useState('');
const [projectCreationDirectoryBusy, setProjectCreationDirectoryBusy] =
useState(false);
const [projectCreationDirectoryStatus, setProjectCreationDirectoryStatus] =
useState('');
const pickInFlightRef = useRef(false);
const projectCreationDirectorySignatureRef = useRef('');
const workspacePreferenceEventGenerationRef = useRef(0);
function applyWorkspacePreferencesSnapshot(
directory: string | null,
): boolean {
const next = directory ?? '';
if (projectCreationDirectorySignatureRef.current === next) {
return false;
}
projectCreationDirectorySignatureRef.current = next;
setProjectCreationDirectoryState(next);
return true;
}
useEffect(() => {
let disposed = false;
let unsubscribe: (() => void) | null = null;
const setup = async () => {
try {
unsubscribe = await subscribeWorkspacePreferences((snapshot) => {
if (!disposed) {
workspacePreferenceEventGenerationRef.current += 1;
applyWorkspacePreferencesSnapshot(
snapshot.projectCreationDirectory,
);
}
});
} catch (error) {
if (!disposed) {
setProjectCreationDirectoryStatus(
error instanceof Error ? error.message : String(error),
);
}
}
if (disposed) {
return;
}
const eventGenerationAtRead =
workspacePreferenceEventGenerationRef.current;
try {
const snapshot = await readWorkspacePreferences();
if (
!disposed &&
workspacePreferenceEventGenerationRef.current ===
eventGenerationAtRead
) {
applyWorkspacePreferencesSnapshot(snapshot.projectCreationDirectory);
}
} catch (error) {
if (!disposed) {
setProjectCreationDirectoryStatus(
error instanceof Error ? error.message : String(error),
);
}
}
};
void setup();
return () => {
disposed = true;
unsubscribe?.();
};
}, []);
const pickProjectCreationDirectory = useCallback(async () => {
if (pickInFlightRef.current) {
@@ -49,7 +112,8 @@ export function useProjectCreationDirectory() {
setProjectCreationDirectoryStatus('已取消');
return;
}
setProjectCreationDirectory(writeProjectCreationDirectory(selected));
const snapshot = await setProjectCreationDirectory(selected);
applyWorkspacePreferencesSnapshot(snapshot.projectCreationDirectory);
setProjectCreationDirectoryStatus('已更新项目创建目录');
} catch (error) {
setProjectCreationDirectoryStatus(
@@ -62,9 +126,16 @@ export function useProjectCreationDirectory() {
}, [projectCreationDirectory]);
const resetProjectCreationDirectory = useCallback(() => {
writeProjectCreationDirectory('');
setProjectCreationDirectory('');
setProjectCreationDirectoryStatus('已恢复默认位置');
void setProjectCreationDirectory(null)
.then((snapshot) => {
applyWorkspacePreferencesSnapshot(snapshot.projectCreationDirectory);
setProjectCreationDirectoryStatus('已恢复默认位置');
})
.catch((error) => {
setProjectCreationDirectoryStatus(
error instanceof Error ? error.message : String(error),
);
});
}, []);
return {
@@ -13,12 +13,13 @@ import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
import { buildRecentProjectRows } from './model';
import {
buildRecentProjectRows,
readRecentWorkspaces,
removeRecentWorkspace,
writeRecentWorkspace,
} from './model';
readWorkspacePreferences,
rememberRecentWorkspace as rememberRecentWorkspacePreference,
removeRecentWorkspace as removeRecentWorkspacePreference,
subscribeWorkspacePreferences,
} from './workspacePreferences';
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
/** 单次检查失败后的就地重试退避,数组长度即重试次数。 */
@@ -107,8 +108,9 @@ async function inspectRecentWorkspaceWithRetry(
}
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const [recentWorkspaces, setRecentWorkspaces] =
useState<string[]>(readRecentWorkspaces);
const setStatusRef = useRef(setStatus);
setStatusRef.current = setStatus;
const [recentWorkspaces, setRecentWorkspaces] = useState<string[]>([]);
const [recentWorkspaceStatuses, setRecentWorkspaceStatuses] = useState<
Record<string, LocalProjectDirectoryStatus | null>
>({});
@@ -124,6 +126,68 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const lastRetryableFailureKeyRef = useRef('');
// 提权类失败在用户再次主动操作前不再自动重试。
const nonRetryablePathsRef = useRef<Set<string>>(new Set());
const recentWorkspaceSignatureRef = useRef('');
const workspacePreferenceEventGenerationRef = useRef(0);
function applyWorkspacePreferencesSnapshot(
recent: readonly string[],
): boolean {
const signature = recent.join('\u0000');
if (recentWorkspaceSignatureRef.current === signature) {
return false;
}
recentWorkspaceSignatureRef.current = signature;
setRecentWorkspaces([...recent]);
setRecentWorkspaceRefreshKey((current) => current + 1);
return true;
}
useEffect(() => {
let disposed = false;
let unsubscribe: (() => void) | null = null;
const setup = async () => {
try {
unsubscribe = await subscribeWorkspacePreferences((snapshot) => {
if (!disposed) {
workspacePreferenceEventGenerationRef.current += 1;
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
}
});
} catch (error) {
if (!disposed) {
setStatusRef.current(
error instanceof Error ? error.message : String(error),
);
}
}
if (disposed) {
return;
}
const eventGenerationAtRead =
workspacePreferenceEventGenerationRef.current;
try {
const snapshot = await readWorkspacePreferences();
if (
!disposed &&
workspacePreferenceEventGenerationRef.current ===
eventGenerationAtRead
) {
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
}
} catch (error) {
if (!disposed) {
setStatusRef.current(
error instanceof Error ? error.message : String(error),
);
}
}
};
void setup();
return () => {
disposed = true;
unsubscribe?.();
};
}, []);
function scheduleFailureRecheck(retryableFailureCount: number) {
window.clearTimeout(failureRecheckTimerRef.current);
@@ -225,8 +289,22 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
function rememberRecentWorkspace(projectPath: string) {
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
nonRetryablePathsRef.current.clear();
setRecentWorkspaces(writeRecentWorkspace(projectPath));
setRecentWorkspaceRefreshKey((current) => current + 1);
const eventGenerationAtCommand =
workspacePreferenceEventGenerationRef.current;
void rememberRecentWorkspacePreference(projectPath)
.then((snapshot) => {
if (
workspacePreferenceEventGenerationRef.current ===
eventGenerationAtCommand
) {
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
}
})
.catch((error) => {
setStatusRef.current(
error instanceof Error ? error.message : String(error),
);
});
}
async function refreshRecentWorkspace(projectPath: string) {
@@ -257,11 +335,26 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
function handleRecentWorkspaceRemove(projectPath: string) {
nonRetryablePathsRef.current.delete(projectPath);
setRecentWorkspaces(removeRecentWorkspace(projectPath));
setRecentWorkspaceStatuses((current) => {
const { [projectPath]: _removed, ...rest } = current;
return rest;
});
const eventGenerationAtCommand =
workspacePreferenceEventGenerationRef.current;
void removeRecentWorkspacePreference(projectPath)
.then((snapshot) => {
if (
workspacePreferenceEventGenerationRef.current ===
eventGenerationAtCommand
) {
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
}
setRecentWorkspaceStatuses((current) => {
const { [projectPath]: _removed, ...rest } = current;
return rest;
});
})
.catch((error) => {
setStatusRef.current(
error instanceof Error ? error.message : String(error),
);
});
}
async function handleRevealProjectDirectory(projectPath: string) {
@@ -0,0 +1,352 @@
import { resolveTauriInvoke } from '../../app/tauri';
import {
canSubscribeTauriEvents,
subscribeTauriEvent,
type TauriEventUnsubscribe,
} from '../../services/tauriEventSubscription';
import {
normalizeProjectCreationDirectory,
normalizeRecentWorkspaces,
} from './model';
export const WORKSPACE_PREFERENCES_CHANGED_EVENT =
'game-creator-workspace-preferences-changed';
export type WorkspacePreferencesSnapshot = {
projectCreationDirectory: string | null;
recentWorkspaces: string[];
chatPromptPolishReminderDisabled: boolean;
};
const EMPTY_WORKSPACE_PREFERENCES: WorkspacePreferencesSnapshot = {
projectCreationDirectory: null,
recentWorkspaces: [],
chatPromptPolishReminderDisabled: false,
};
const LEGACY_RECENT_WORKSPACES_STORAGE_KEY =
'genarrative-ai-game-creator.recent-workspaces.v1';
const LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY =
'genarrative-ai-game-creator.project-creation-directory.v1';
let browserSnapshot: WorkspacePreferencesSnapshot = {
...EMPTY_WORKSPACE_PREFERENCES,
};
let legacyMigrationPromise: Promise<WorkspacePreferencesSnapshot> | null = null;
function isTestRuntime() {
return import.meta.env.MODE === 'test';
}
function normalizeSnapshot(
value: Partial<WorkspacePreferencesSnapshot> | null | undefined,
): WorkspacePreferencesSnapshot {
return {
projectCreationDirectory:
normalizeProjectCreationDirectory(
typeof value?.projectCreationDirectory === 'string'
? value.projectCreationDirectory
: '',
) || null,
recentWorkspaces: normalizeRecentWorkspaces(
Array.isArray(value?.recentWorkspaces) ? value.recentWorkspaces : [],
),
chatPromptPolishReminderDisabled:
value?.chatPromptPolishReminderDisabled === true,
};
}
function updateBrowserSnapshot(
snapshot: WorkspacePreferencesSnapshot,
): WorkspacePreferencesSnapshot {
browserSnapshot = normalizeSnapshot(snapshot);
return browserSnapshot;
}
function requireNativeSnapshot(
snapshot: WorkspacePreferencesSnapshot | null,
message: string,
) {
if (!snapshot) {
throw new Error(message);
}
return snapshot;
}
function readLegacySnapshot(): WorkspacePreferencesSnapshot {
try {
const recentRaw = window.localStorage.getItem(
LEGACY_RECENT_WORKSPACES_STORAGE_KEY,
);
const directoryRaw = window.localStorage.getItem(
LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
);
const recentParsed: unknown = recentRaw ? JSON.parse(recentRaw) : [];
const directoryParsed: unknown = directoryRaw
? JSON.parse(directoryRaw)
: '';
return normalizeSnapshot({
projectCreationDirectory:
typeof directoryParsed === 'string' ? directoryParsed : '',
recentWorkspaces: Array.isArray(recentParsed) ? recentParsed : [],
});
} catch {
return { ...EMPTY_WORKSPACE_PREFERENCES };
}
}
function clearLegacySnapshot() {
try {
window.localStorage.removeItem(LEGACY_RECENT_WORKSPACES_STORAGE_KEY);
window.localStorage.removeItem(
LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
);
} catch {
// 隐私模式或受限 WebView 不可写时,Rust 快照仍是正式数据源。
}
}
function writeLegacySnapshot(snapshot: WorkspacePreferencesSnapshot) {
try {
if (snapshot.recentWorkspaces.length > 0) {
window.localStorage.setItem(
LEGACY_RECENT_WORKSPACES_STORAGE_KEY,
JSON.stringify(snapshot.recentWorkspaces),
);
} else {
window.localStorage.removeItem(LEGACY_RECENT_WORKSPACES_STORAGE_KEY);
}
if (snapshot.projectCreationDirectory) {
window.localStorage.setItem(
LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
JSON.stringify(snapshot.projectCreationDirectory),
);
} else {
window.localStorage.removeItem(
LEGACY_PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
);
}
} catch {
// 仅测试兼容 fallback;生产命令失败会向调用方报告。
}
}
async function migrateLegacySnapshot(
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
snapshot: WorkspacePreferencesSnapshot,
) {
if (
snapshot.projectCreationDirectory !== null ||
snapshot.recentWorkspaces.length > 0
) {
return snapshot;
}
const legacy = readLegacySnapshot();
if (
legacy.projectCreationDirectory === null &&
legacy.recentWorkspaces.length === 0
) {
return snapshot;
}
const migrated = await invoke<WorkspacePreferencesSnapshot | null>(
'migrate_workspace_preferences',
legacy,
);
if (!migrated) {
return legacy;
}
clearLegacySnapshot();
return normalizeSnapshot(migrated);
}
export async function readWorkspacePreferences(): Promise<WorkspacePreferencesSnapshot> {
const invoke = resolveTauriInvoke();
if (!invoke) {
return browserSnapshot;
}
let snapshot: WorkspacePreferencesSnapshot;
try {
const nativeSnapshot = await invoke<WorkspacePreferencesSnapshot | null>(
'read_workspace_preferences',
);
if (!nativeSnapshot) {
if (!isTestRuntime()) {
return browserSnapshot;
}
return updateBrowserSnapshot(readLegacySnapshot());
}
snapshot = normalizeSnapshot(nativeSnapshot);
} catch (error) {
if (!isTestRuntime()) {
throw error;
}
return updateBrowserSnapshot(readLegacySnapshot());
}
if (!legacyMigrationPromise) {
legacyMigrationPromise = migrateLegacySnapshot(invoke, snapshot).finally(
() => {
legacyMigrationPromise = null;
},
);
}
return updateBrowserSnapshot(await legacyMigrationPromise);
}
export async function setProjectCreationDirectory(
directory: string | null,
): Promise<WorkspacePreferencesSnapshot> {
const normalized = normalizeProjectCreationDirectory(directory ?? '');
const invoke = resolveTauriInvoke();
if (!invoke) {
return updateBrowserSnapshot({
...browserSnapshot,
projectCreationDirectory: normalized || null,
});
}
try {
return updateBrowserSnapshot(
normalizeSnapshot(
requireNativeSnapshot(
await invoke<WorkspacePreferencesSnapshot | null>(
'set_project_creation_directory',
{ directory: normalized || null },
),
'工作区偏好写入没有返回快照',
),
),
);
} catch (error) {
if (!isTestRuntime()) {
throw error;
}
const snapshot = updateBrowserSnapshot({
...browserSnapshot,
projectCreationDirectory: normalized || null,
});
writeLegacySnapshot(snapshot);
return snapshot;
}
}
export async function rememberRecentWorkspace(
projectPath: string,
): Promise<WorkspacePreferencesSnapshot> {
const invoke = resolveTauriInvoke();
if (!invoke) {
return updateBrowserSnapshot({
...browserSnapshot,
recentWorkspaces: normalizeRecentWorkspaces([
projectPath,
...browserSnapshot.recentWorkspaces,
]),
});
}
try {
return updateBrowserSnapshot(
normalizeSnapshot(
requireNativeSnapshot(
await invoke<WorkspacePreferencesSnapshot | null>(
'remember_recent_workspace',
{ projectPath },
),
'最近工作区写入没有返回快照',
),
),
);
} catch (error) {
if (!isTestRuntime()) {
throw error;
}
const snapshot = updateBrowserSnapshot({
...browserSnapshot,
recentWorkspaces: normalizeRecentWorkspaces([
projectPath,
...browserSnapshot.recentWorkspaces,
]),
});
writeLegacySnapshot(snapshot);
return snapshot;
}
}
export async function removeRecentWorkspace(
projectPath: string,
): Promise<WorkspacePreferencesSnapshot> {
const invoke = resolveTauriInvoke();
if (!invoke) {
return updateBrowserSnapshot({
...browserSnapshot,
recentWorkspaces: browserSnapshot.recentWorkspaces.filter(
(path) => path !== projectPath.trim(),
),
});
}
try {
return updateBrowserSnapshot(
normalizeSnapshot(
requireNativeSnapshot(
await invoke<WorkspacePreferencesSnapshot | null>(
'remove_recent_workspace',
{ projectPath },
),
'最近工作区移除没有返回快照',
),
),
);
} catch (error) {
if (!isTestRuntime()) {
throw error;
}
const snapshot = updateBrowserSnapshot({
...browserSnapshot,
recentWorkspaces: browserSnapshot.recentWorkspaces.filter(
(path) => path !== projectPath.trim(),
),
});
writeLegacySnapshot(snapshot);
return snapshot;
}
}
export async function setChatPromptPolishReminderDisabled(
disabled: boolean,
): Promise<WorkspacePreferencesSnapshot> {
const invoke = resolveTauriInvoke();
if (!invoke) {
return updateBrowserSnapshot({
...browserSnapshot,
chatPromptPolishReminderDisabled: disabled,
});
}
return updateBrowserSnapshot(
normalizeSnapshot(
requireNativeSnapshot(
await invoke<WorkspacePreferencesSnapshot | null>(
'set_chat_prompt_polish_reminder_disabled',
{ disabled },
),
'聊天提醒偏好写入没有返回快照',
),
),
);
}
export function subscribeWorkspacePreferences(
onChanged: (snapshot: WorkspacePreferencesSnapshot) => void,
): Promise<TauriEventUnsubscribe> {
if (!canSubscribeTauriEvents()) {
return Promise.resolve(() => {});
}
return subscribeTauriEvent<WorkspacePreferencesSnapshot>(
WORKSPACE_PREFERENCES_CHANGED_EVENT,
(event) => {
const snapshot = updateBrowserSnapshot(normalizeSnapshot(event.payload));
onChanged(snapshot);
},
);
}
export function resetWorkspacePreferencesForTests() {
browserSnapshot = { ...EMPTY_WORKSPACE_PREFERENCES };
legacyMigrationPromise = null;
}
@@ -10,14 +10,10 @@ import {
import { resolveTauriInvoke } from '../../app/tauri';
import type { GameCreatorAppConfigView } from '../../app/types';
import type {
ClientLlmModel,
ClientLlmModelCatalog,
} from '../../services/clientApi';
import { ClientAuthRequestError } from '../../services/clientApi';
import { ClientHttpTimeoutError } from '../../services/clientHttp';
import {
cachedLlmModelCatalog,
type ClientLlmModel,
type ClientLlmModelCatalog,
LLM_CONFIG_CHANGED_EVENT,
LlmModelCatalogConfigError,
refreshLlmModelCatalog,
@@ -31,12 +27,19 @@ export type ConversationModelSelectHandle = {
/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */
class ModelSelectionConfigError extends Error {}
/**
* 目录失败文案:Rust typed command 已把结果归类成稳定字符串,这里不再依赖带 HTTP 状态码的
* 渲染层错误类型,也不把服务端原文(可能含内部地址)直接铺给用户。
*/
function modelCatalogErrorMessage(error: unknown) {
if (error instanceof LlmModelCatalogConfigError) return error.message;
if (error instanceof ClientHttpTimeoutError)
const message = error instanceof Error ? error.message : String(error ?? '');
if (message.includes('authentication-required')) {
return '模型列表加载失败:登录已失效,请重新登录';
}
if (/超时|timed?\s*out|timeout/iu.test(message)) {
return '模型列表请求超时,请重试';
if (error instanceof ClientAuthRequestError && error.status)
return `模型列表加载失败(HTTP ${error.status})`;
}
return '模型列表加载失败';
}
@@ -41,11 +41,13 @@ import {
import { createPortal } from 'react-dom';
import RichTextInput from '../../components/RichTextInput';
import { captureClientError } from '../../services/errorReporting';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import {
chatPromptDraftKey,
readChatPromptPolishReminderDisabled,
shouldRemindChatPromptPolish,
subscribeChatPromptPolishReminderDisabled,
writeChatPromptPolishReminderDisabled,
} from './chatPromptPolish';
import { ChatPromptPolishReminder } from './ChatPromptPolishReminder';
@@ -656,9 +658,8 @@ function ResourceReferenceEditor({
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
const [reminderOpen, setReminderOpen] = useState(false);
const [reminderPolishing, setReminderPolishing] = useState(false);
const [reminderDisabled, setReminderDisabled] = useState(() =>
readChatPromptPolishReminderDisabled(),
);
// 偏好正式数据在 Rust:初始值只是「未关闭」的占位,挂载后由快照与事件补齐。
const [reminderDisabled, setReminderDisabled] = useState(false);
const acknowledgedDraftKeyRef = useRef<string | null>(null);
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
const liveDraftRef = useRef<ChatComposerDraft>({
@@ -670,6 +671,44 @@ function ResourceReferenceEditor({
const reminderDisabledRef = useRef(reminderDisabled);
reminderDisabledRef.current = reminderDisabled;
/**
* 偏好写入代次。
*
* 偏好正式数据在 Rust:挂载时读一次快照并订阅事件。用户点击「不再提醒」或 Rust 事件
* 到达都会推进代次,迟到的挂载快照因此不能把更新的选择覆盖回旧值。
*/
const reminderPreferenceGenerationRef = useRef(0);
useEffect(() => {
let disposed = false;
let unsubscribe: (() => void) | null = null;
void subscribeChatPromptPolishReminderDisabled((disabled) => {
if (disposed) return;
reminderPreferenceGenerationRef.current += 1;
setReminderDisabled(disabled);
})
.then((release) => {
if (disposed) release();
else unsubscribe = release;
})
.catch(() => undefined);
// 快照读取与订阅并发,但只有「读取期间没有任何更新」时才允许写回。
const generationAtRead = reminderPreferenceGenerationRef.current;
void readChatPromptPolishReminderDisabled().then((disabled) => {
if (
disposed ||
reminderPreferenceGenerationRef.current !== generationAtRead
) {
return;
}
setReminderDisabled(disabled);
});
return () => {
disposed = true;
unsubscribe?.();
};
}, []);
/**
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
@@ -765,8 +804,18 @@ function ResourceReferenceEditor({
}, [reminderPolishing, runPolish, submitCurrentDraft]);
const setPromptPolishReminderDisabled = useCallback((disabled: boolean) => {
writeChatPromptPolishReminderDisabled(disabled);
// 先本地收口交互反馈,再以 Rust 落盘后的投影为准;失败回退并提示。
reminderPreferenceGenerationRef.current += 1;
setReminderDisabled(disabled);
void writeChatPromptPolishReminderDisabled(disabled)
.then((persisted) => setReminderDisabled(persisted))
.catch((error) => {
setReminderDisabled(!disabled);
captureClientError(error, {
source: 'chat-prompt-polish',
action: 'set-reminder-disabled',
});
});
}, []);
// 发送前提醒拦截:在捕获阶段拦下 form 的 submit,阻止 React 的表单提交处理器执行,
@@ -1,60 +1,51 @@
import { resolveTauriInvoke } from '../../app/tauri';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import {
readWorkspacePreferences,
setChatPromptPolishReminderDisabled as setChatPromptPolishReminderPreference,
subscribeWorkspacePreferences,
} from '../app-shell/workspacePreferences';
/**
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
* 「不再提醒」偏好由 Rust 客户端偏好存储持有,不进 manifest、不进后端。
*
* **刻意只留这一个全局键,不按入口分域**:它关掉的是聊天输入区那一条「发送前提醒」
* **刻意只留这一个全局偏好,不按入口分域**:它关掉的是聊天输入区那一条「发送前提醒」
* (纯文本 ≥40 字)——资源侧的生成素材 / 快速编辑没有这条提醒可关,润色在那里
* 是显式按钮动作,不存在被打断的场景。分域只有在「每个入口都有可关的提醒」时才有意义,
* 现在分域等于给不存在的提醒提前留位。
*/
export const CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY =
'agc.chat.prompt-polish-reminder.disabled';
/**
* 发送前提醒判据(长度阈值)。
* 纯文本(trim 后)达到该长度且用户没有关闭提醒、且本轮草稿还没有润色过 / 确认过时,
* 点发送会先弹出独立提醒面板,而不是直接发出。
*
* 渲染层只在内存里缓存当前投影:初始为「未关闭」,挂载后读一次 Rust 快照并订阅偏好事件;
* 原生桥不可用(浏览器预览、单元测试)时保持默认值,不写任何本地存储。
*/
export const CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH = 40;
function readLocalStorage(): Storage | null {
/** 读取 Rust 偏好投影;原生桥不可用时按未关闭处理。 */
export async function readChatPromptPolishReminderDisabled(): Promise<boolean> {
if (!resolveTauriInvoke()) return false;
try {
return typeof window === 'undefined' ? null : window.localStorage;
} catch {
return null;
}
}
/** 读取本机的「不再提醒」偏好;存储不可用时按未关闭处理。 */
export function readChatPromptPolishReminderDisabled(): boolean {
const storage = readLocalStorage();
if (!storage) {
return false;
}
try {
return storage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY) === 'true';
const snapshot = await readWorkspacePreferences();
return snapshot.chatPromptPolishReminderDisabled;
} catch {
return false;
}
}
/** 写入本机的「不再提醒」偏好;存储不可用时静默跳过,不影响本次发送。 */
export function writeChatPromptPolishReminderDisabled(disabled: boolean) {
const storage = readLocalStorage();
if (!storage) {
return;
}
try {
if (disabled) {
storage.setItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, 'true');
} else {
storage.removeItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY);
}
} catch {
// 浏览器隐私模式等场景下 localStorage 不可写:偏好只当次生效,不阻断发送。
}
/** 写入 Rust 偏好并返回落盘后的投影;原生桥不可用时只当成次生效。 */
export async function writeChatPromptPolishReminderDisabled(
disabled: boolean,
): Promise<boolean> {
if (!resolveTauriInvoke()) return disabled;
const snapshot = await setChatPromptPolishReminderPreference(disabled);
return snapshot.chatPromptPolishReminderDisabled;
}
/** 订阅 Rust 偏好事件,返回幂等释放函数。 */
export function subscribeChatPromptPolishReminderDisabled(
onChanged: (disabled: boolean) => void,
): Promise<() => void> {
return subscribeWorkspacePreferences((snapshot) => {
onChanged(snapshot.chatPromptPolishReminderDisabled);
});
}
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
@@ -66,7 +57,7 @@ export function chatPromptDraftKey(
/**
* 发送前提醒判据(全部满足才提醒):
* 1. 提醒没有被用户在偏好里关掉(本机 localStorage);
* 1. 提醒没有被用户在 Rust 客户端偏好里关掉;
* 2. 当前草稿指纹不等于「本轮已确认草稿」指纹 —— 即本轮还没有润色过、也没有选过「使用原文提交」;
* 3. 纯文本(trim 后)长度达到 {@link CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH}。
*/
@@ -8,6 +8,8 @@ import {
} from './resourceCanvasAssetGenerationTaskModel';
export const RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS = 2_000;
export const RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT =
'game-creator-asset-generation-task-changed';
/**
* 账本里连续多少次找不到这条任务就放弃等待。
@@ -43,6 +45,15 @@ export type ResourceCanvasAssetGenerationSettlement = {
export type ResourceCanvasAssetGenerationQueueDeps = {
invoke(command: string, args: Record<string, unknown>): Promise<unknown>;
/**
* Rust 账本变化通知。真实 Tauri 运行时使用事件唤醒,测试/浏览器预览未提供桥接时
* 才回退到注入的 wait(不在产品运行时建立定时器)。
*/
subscribe?: (
projectPath: string,
taskId: string,
onChanged: () => void,
) => Promise<() => void>;
/**
* 当前项目路径。
*
@@ -141,6 +152,35 @@ export function createResourceCanvasAssetGenerationQueue(
task: ResourceCanvasAssetGenerationTask,
): Promise<ResourceCanvasAssetGenerationSettlement> {
const projectPath = deps.projectPath();
let releaseTaskSubscription: (() => void) | null = null;
let taskEventPending = false;
let resolveTaskEvent: (() => void) | null = null;
const waitForTaskEvent = () => {
if (taskEventPending) {
taskEventPending = false;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
resolveTaskEvent = resolve;
});
};
if (deps.subscribe) {
try {
releaseTaskSubscription = await deps.subscribe(
projectPath,
task.taskId,
() => {
taskEventPending = true;
const resolve = resolveTaskEvent;
resolveTaskEvent = null;
resolve?.();
},
);
} catch {
// 事件桥不可用时仅回退到测试/浏览器预览的注入等待器。
releaseTaskSubscription = null;
}
}
// 命令名写成字面量:`scripts/check-config.mjs` 的 invoke 门禁按字符串字面量登记调用方,
// 抽成常量会让这两条 IPC 被判成「没有前端调用方」。
/*
@@ -170,76 +210,84 @@ export function createResourceCanvasAssetGenerationQueue(
: {}),
}),
})) as LocalProjectAssetGenerationTaskRecord;
let started: LocalProjectAssetGenerationTaskRecord;
try {
started = await (deps.refreshPlatformSession
? deps.refreshPlatformSession(start)
: start());
} catch (error) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: errorMessage(error),
};
}
replaceFromRecord(task, started);
let missingRecordPolls = 0;
let readFailed = false;
let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
for (;;) {
let records: LocalProjectAssetGenerationTaskRecord[];
let started: LocalProjectAssetGenerationTaskRecord;
try {
records = (await deps.invoke('list_local_project_asset_generations', {
projectPath,
})) as LocalProjectAssetGenerationTaskRecord[];
started = await (deps.refreshPlatformSession
? deps.refreshPlatformSession(start)
: start());
} catch (error) {
// IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛——
// 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。
records = [];
readFailed = true;
lastReadError = errorMessage(error);
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: errorMessage(error),
};
}
if (!Array.isArray(records)) {
records = [];
readFailed = true;
lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
}
const record = records.find((item) => item.taskId === task.taskId);
if (record) {
missingRecordPolls = 0;
readFailed = false;
replaceFromRecord(task, record);
if (isTerminalStatus(record.status)) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: record.status === 'completed' ? 'completed' : 'failed',
record,
error: record.error,
};
replaceFromRecord(task, started);
let missingRecordPolls = 0;
let readFailed = false;
let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
for (;;) {
let records: LocalProjectAssetGenerationTaskRecord[];
try {
records = (await deps.invoke('list_local_project_asset_generations', {
projectPath,
})) as LocalProjectAssetGenerationTaskRecord[];
} catch (error) {
// IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛——
// 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。
records = [];
readFailed = true;
lastReadError = errorMessage(error);
}
} else {
missingRecordPolls += 1;
if (
missingRecordPolls >=
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT
) {
// 「读不到通道」与「读到数组但没有这条」是两件事,文案必须能区分:前者是账本不可用,
// 后者是记录被账本上限淘汰或项目被换掉。
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: readFailed
? `生成任务状态读取失败,已停止等待:${lastReadError}`
: '生成任务账本里已找不到这条任务,已停止等待',
};
if (!Array.isArray(records)) {
records = [];
readFailed = true;
lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
}
const record = records.find((item) => item.taskId === task.taskId);
if (record) {
missingRecordPolls = 0;
readFailed = false;
replaceFromRecord(task, record);
if (isTerminalStatus(record.status)) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: record.status === 'completed' ? 'completed' : 'failed',
record,
error: record.error,
};
}
} else {
missingRecordPolls += 1;
if (
missingRecordPolls >=
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT
) {
// 「读不到通道」与「读到数组但没有这条」是两件事,文案必须能区分:前者是账本不可用,
// 后者是记录被账本上限淘汰或项目被换掉。
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: readFailed
? `生成任务状态读取失败,已停止等待:${lastReadError}`
: '生成任务账本里已找不到这条任务,已停止等待',
};
}
}
if (releaseTaskSubscription) {
await waitForTaskEvent();
} else {
await wait(pollIntervalMillis);
}
}
await wait(pollIntervalMillis);
} finally {
releaseTaskSubscription?.();
}
}
@@ -26,6 +26,7 @@ import {
import { appUpdateCheckEnabled } from '../../app/featureFlags';
import { resolveTauriInvoke } from '../../app/tauri';
import {
type AgcExtensionSummary,
type AgcPluginPanel,
type AgcPluginSummary,
type ClientExtensionImportResult,
@@ -38,6 +39,7 @@ import {
import { checkForAppUpdate } from '../../services/appUpdate';
import { notifyLlmConfigChanged } from '../../services/llmModelCatalog';
import {
AGC_PLUGIN_STATE_CHANGED_EVENT,
listAgcExtensions,
reloadAgcPlugin,
setAgcPluginEnabled,
@@ -45,6 +47,10 @@ import {
startAgcPlugin,
stopAgcPlugin,
} from '../../services/pluginHost';
import {
canSubscribeTauriEvents,
subscribeTauriEvent,
} from '../../services/tauriEventSubscription';
import { useProjectCreationDirectory } from '../app-shell/useProjectCreationDirectory';
import { PluginPanelHost } from '../plugins/PluginPanelHost';
import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort';
@@ -234,7 +240,7 @@ export function RuntimeConfigDialog({
const [activeSection, setActiveSection] =
useState<RuntimeSettingsSection>('general');
/**
* 「项目创建目录」是客户端本地偏好(localStorage),不随下面的配置文件一起保存:
* 「项目创建目录」由 Rust 工作区偏好持有,不随下面的运行时配置一起保存:
* 选择目录当场生效,做游戏 / 做方案与模板建项下一次建项就落在该目录下。
*/
const projectCreationDirectory = useProjectCreationDirectory();
@@ -270,8 +276,43 @@ export function RuntimeConfigDialog({
useEffect(() => {
if (activeSection !== 'extensions') return;
const timer = window.setInterval(() => void readAgcPlugins(), 1000);
return () => window.clearInterval(timer);
let disposed = false;
let unsubscribe: (() => void) | null = null;
void readAgcPlugins();
if (!canSubscribeTauriEvents()) {
return undefined;
}
void subscribeTauriEvent<AgcExtensionSummary[]>(
AGC_PLUGIN_STATE_CHANGED_EVENT,
(event) => {
if (disposed) return;
setAgcPlugins(
event.payload.flatMap((extension) =>
extension.kind === 'plugin' && extension.plugin
? [extension.plugin]
: [],
),
);
},
)
.then((release) => {
if (disposed) {
release();
} else {
unsubscribe = release;
}
})
.catch((error) => {
if (!disposed) {
setAgcPluginsStatus(
error instanceof Error ? error.message : String(error),
);
}
});
return () => {
disposed = true;
unsubscribe?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSection]);
@@ -18,7 +18,6 @@ import {
currentPlatformSessionGeneration,
subscribePlatformSessionGeneration,
} from '../../services/platformSession';
import { readProjectCreationDirectory } from '../app-shell/model';
import {
collectGameTemplateRuntimes,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
@@ -268,8 +267,6 @@ export function useTemplateLibrary({
templateVersion: template.templateVersion,
name: null,
planning: false,
// 与首页自动建项共用一个「项目创建目录」偏好;没选时由 Rust 侧回落到默认位置。
projectsRoot: readProjectCreationDirectory() || null,
},
);
if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止');
@@ -0,0 +1,62 @@
import type {
ConfirmWechatProfileRechargeOrderResponse,
CreateProfileRechargeOrderResponse,
ProfileRechargeCenterResponse,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
} from '../../../../packages/shared/src/contracts/runtime';
import { resolveTauriInvoke } from '../app/tauri';
import { withPlatformSessionRefreshRetry } from './platformSessionRetry';
/**
* 账户与钱包 typed façade。
*
* 平台 origin、Bearer、响应 envelope 解析与错误分类都在 Rust;渲染层只提交结构化业务
* 参数。access token 过期时 Rust 返回稳定的 `authentication-required`,这里复用既有
* 会话续期链路重试一次;付费订单的未知结果不自动重放。
*/
function invokeAccount<T>(
command: string,
args?: Record<string, unknown>,
): Promise<T> {
return withPlatformSessionRefreshRetry(() => {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在 Tauri App 内读取账户信息');
}
return invoke<T>(command, args);
});
}
export function getClientProfileRechargeCenter() {
return invokeAccount<ProfileRechargeCenterResponse>(
'read_profile_recharge_center',
);
}
export function getClientProfileWalletLedger() {
return invokeAccount<ProfileWalletLedgerResponse>(
'read_profile_wallet_ledger',
);
}
export function createClientProfileRechargeOrder(productId: string) {
return invokeAccount<CreateProfileRechargeOrderResponse>(
'create_profile_recharge_order',
{ productId },
);
}
export function confirmClientWechatProfileRechargeOrder(orderId: string) {
return invokeAccount<ConfirmWechatProfileRechargeOrderResponse>(
'confirm_wechat_profile_recharge_order',
{ orderId },
);
}
export function redeemClientProfileRewardCode(code: string) {
return invokeAccount<RedeemProfileRewardCodeResponse>(
'redeem_profile_reward_code',
{ code },
);
}
@@ -0,0 +1,56 @@
import type { TauriInvoke } from '../app/types';
/**
* 统一错误诊断引用在 DirectProject 失败文案里的标记。
*
* Rust 的失败投影以「;详情:.agent/runtime/errors/<eventId>.json」收尾,渲染层只按这个
* 字面量把 `detailRef` 取回来,再走只读命令读有界、脱敏的诊断;错误分类与脱敏都在 Rust
* 侧,不在 React 侧重判。
*
* 这个后缀是**跨层协议**:改文案或改标记时必须同时改这里的解析和两侧测试
* (Rust:`direct_codex_failure_text_keeps_the_detail_ref_marker_for_the_renderer`;
* 本文件:`agentRuntimeErrorDetail.test.ts`)。
*/
export const AGENT_RUNTIME_ERROR_DETAIL_MARKER = '详情:';
const AGENT_RUNTIME_ERROR_DETAIL_REF_PATTERN =
/详情:(\.agent\/runtime\/errors\/[^\s;]+)/;
/**
* 从失败文案里取出诊断引用。
*
* 只做形状判别(目录前缀 + `.json` 后缀):引用的目录归属、穿越和大小上限由原生命令
* `read_agent_runtime_error_detail` 校验,这里不放宽也不替代那道门。
*/
export function extractAgentRuntimeErrorDetailRef(
message: string,
): string | null {
const matched = message.match(AGENT_RUNTIME_ERROR_DETAIL_REF_PATTERN)?.[1];
if (!matched || !matched.endsWith('.json')) return null;
return matched;
}
/**
* 按已知引用读取诊断正文。
*
* 面板把引用与展示文案分开持有(文案里不再拼引用),因此按需读取走这一入口;引用是否在
* 项目错误目录内、是否是单个 `.json` 文件名仍由原生命令校验。
*
* 读不到(旧项目无 sidecar、权限拒绝、命令缺失)时返回空串:诊断是附加信息,缺了只保留
* 安全 `publicText`,不能把读取失败升级成新的用户可见错误。
*/
export async function readAgentRuntimeErrorDetailByRef(
invoke: TauriInvoke,
projectPath: string,
detailRef: string,
): Promise<string> {
if (!detailRef.trim()) return '';
try {
return await invoke<string>('read_agent_runtime_error_detail', {
projectPath,
detailRef,
});
} catch {
return '';
}
}

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