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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+12
-9
@@ -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 '模型列表加载失败';
|
||||
}
|
||||
|
||||
|
||||
+53
-4
@@ -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}。
|
||||
*/
|
||||
|
||||
+111
-63
@@ -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
Reference in New Issue
Block a user