合并master并保留双侧决策记录

合并 origin/master 的 UI 编辑器、GDD 审批与前端修复。

保留本分支 LLM Router、Direct 过程卡与私有路径相关实现和决策记录。

解决 decision-log 文档冲突,完整保留双方新增决策条目。
This commit is contained in:
2026-09-02 19:31:12 +08:00
39 changed files with 1824 additions and 97 deletions
@@ -4,6 +4,7 @@ import {
type FormEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from 'react';
@@ -49,6 +50,36 @@ type ClientRuntimeErrorBoundaryState = {
errorMessage: string;
};
type AuthCheckStage = 'token' | 'refresh' | 'me' | 'runner';
const AUTH_CHECK_STAGE_LABELS: Record<AuthCheckStage, string> = {
token: '读取本地登录凭据',
refresh: '刷新登录状态',
me: '确认当前用户',
runner: '连接本地运行时',
};
const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000;
// Existing endpoint probes can consume the 10s IPC budget before Runner's
// 30s startup deadline. Keep the UI fence slightly above that worst case.
const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000;
function withAuthCheckTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
) {
let timeoutId: number | undefined;
const timeout = new Promise<T>((_, reject) => {
timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
}
export class ClientRuntimeErrorBoundary extends Component<
ClientRuntimeErrorBoundaryProps,
ClientRuntimeErrorBoundaryState
@@ -101,6 +132,11 @@ export function AuthenticatedClient({
'checking' | 'authenticated' | 'unauthenticated'
>('checking');
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
const [authCheckStage, setAuthCheckStage] = useState<AuthCheckStage>('token');
const [authCheckElapsedSeconds, setAuthCheckElapsedSeconds] = useState(0);
const [authCheckError, setAuthCheckError] = useState('');
const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0);
const authCheckRunRef = useRef(0);
const [loginMode, setLoginMode] = useState<'code' | 'password'>('code');
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
@@ -147,100 +183,162 @@ export function AuthenticatedClient({
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');
try {
if (!getStoredAuthAccessToken()) {
const refreshed = await refreshPlatformSessionForGeneration(
hydrationGeneration,
hydrationApiBaseUrl,
setAuthCheckStage('refresh');
const refreshed = await withAuthCheckTimeout(
refreshPlatformSessionForGeneration(
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'刷新登录状态超时,请检查服务器地址和网络后重试',
);
if (!refreshed) {
if (isActiveRun()) {
setAuthStatus('unauthenticated');
}
return;
}
}
const user = await getCurrentClientAuthUser(hydrationApiBaseUrl);
if (disposed) {
if (!isActiveRun()) return;
setAuthCheckStage('me');
const user = await withAuthCheckTimeout(
getCurrentClientAuthUser(hydrationApiBaseUrl),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'读取当前用户超时,请检查服务器地址和网络后重试',
);
if (!isActiveRun()) {
return;
}
if (user) {
const committedGeneration = await commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
hydrationApiBaseUrl,
setAuthCheckStage('runner');
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null) {
return;
}
if (disposed) {
if (!isActiveRun()) {
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
return;
}
clearStoredAuthAccessToken();
setAuthStatus('unauthenticated');
} catch (error) {
if (disposed) {
if (!isActiveRun()) {
return;
}
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(error)
) {
setLoginStatus(
getClientAuthErrorMessage(error, '登录服务暂时不可用,请稍后重试'),
const message = getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
setAuthStatus('unauthenticated');
return;
}
if (getStoredAuthAccessToken()) {
try {
const refreshed = await refreshPlatformSessionForGeneration(
hydrationGeneration,
hydrationApiBaseUrl,
if (!isActiveRun()) return;
setAuthCheckStage('refresh');
const refreshed = await withAuthCheckTimeout(
refreshPlatformSessionForGeneration(
hydrationGeneration,
hydrationApiBaseUrl,
),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'刷新登录状态超时,请检查服务器地址和网络后重试',
);
if (!refreshed) {
if (isActiveRun()) {
setAuthStatus('unauthenticated');
}
return;
}
const user = await getCurrentClientAuthUser(hydrationApiBaseUrl);
if (disposed) {
if (!isActiveRun()) return;
setAuthCheckStage('me');
const user = await withAuthCheckTimeout(
getCurrentClientAuthUser(hydrationApiBaseUrl),
AUTH_CHECK_REQUEST_TIMEOUT_MS,
'读取当前用户超时,请检查服务器地址和网络后重试',
);
if (!isActiveRun()) {
return;
}
if (user) {
const committedGeneration =
await commitAuthenticatedPlatformSession(
if (!isActiveRun()) return;
setAuthCheckStage('runner');
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
hydrationApiBaseUrl,
);
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null) {
return;
}
if (disposed) {
if (!isActiveRun()) {
return;
}
setAuthCheckError('');
setAuthUser(user);
setAuthStatus('authenticated');
return;
}
} catch (retryError) {
if (!isActiveRun()) {
return;
}
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(retryError)
) {
setLoginStatus(
getClientAuthErrorMessage(
retryError,
'登录服务暂时不可用,请稍后重试',
),
const message = getClientAuthErrorMessage(
retryError,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
setAuthStatus('unauthenticated');
return;
}
}
}
if (!isActiveRun()) {
return;
}
if (isClientAuthRecoverableCheckError(error)) {
const message = getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
}
clearStoredAuthAccessToken();
setAuthStatus('unauthenticated');
}
@@ -249,7 +347,18 @@ export function AuthenticatedClient({
return () => {
disposed = true;
};
}, []);
}, [authCheckRetryKey]);
useEffect(() => {
if (authStatus !== 'checking') {
return;
}
setAuthCheckElapsedSeconds(0);
const timer = window.setInterval(() => {
setAuthCheckElapsedSeconds((current) => current + 1);
}, 1000);
return () => window.clearInterval(timer);
}, [authStatus, authCheckRetryKey]);
useEffect(
() =>
@@ -358,6 +467,7 @@ export function AuthenticatedClient({
return;
}
setAuthUser(user);
setAuthCheckError('');
setAuthStatus('authenticated');
setCode('');
setPassword('');
@@ -383,6 +493,7 @@ export function AuthenticatedClient({
nativeClearError = error;
}
setAuthUser(null);
setAuthCheckError('');
setAuthStatus('unauthenticated');
setLoginStatus(
nativeClearError
@@ -392,6 +503,7 @@ export function AuthenticatedClient({
}
if (authStatus === 'checking') {
const stageLabel = AUTH_CHECK_STAGE_LABELS[authCheckStage];
return (
<main
className="client-auth-shell platform-theme platform-theme--light"
@@ -400,6 +512,14 @@ export function AuthenticatedClient({
<section className="client-auth-panel">
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
<h1>正在检查登录状态</h1>
<p className="client-auth-status" aria-live="polite">
{stageLabel} · 已等待 {authCheckElapsedSeconds} 秒
</p>
<p className="client-auth-status" aria-live="polite">
{authCheckElapsedSeconds >= 8
? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试'
: '正在恢复会话,请稍候'}
</p>
</section>
</main>
);
@@ -417,6 +537,23 @@ export function AuthenticatedClient({
<h1>登录陶泥儿 GameAgent</h1>
<p>登录后进入首页和本地项目工作区</p>
</div>
{authCheckError ? (
<div role="alert" className="client-auth-status">
<p>{authCheckError}</p>
<button
type="button"
onClick={() => {
beginPlatformSessionTransition();
setAuthCheckError('');
setAuthStatus('checking');
setAuthCheckRetryKey((current) => current + 1);
}}
disabled={loginBusy || codeBusy}
>
重试登录状态检查
</button>
</div>
) : null}
<label>
服务器
<select
@@ -523,7 +660,9 @@ export function AuthenticatedClient({
<button type="submit" disabled={loginBusy}>
{loginBusy ? '登录中' : '登录'}
</button>
<p className="client-auth-status">{loginStatus}</p>
{!authCheckError ? (
<p className="client-auth-status">{loginStatus}</p>
) : null}
</form>
</main>
);
@@ -410,7 +410,11 @@ export function GddApprovalCard({
const canDecide = Boolean(
pending && state.state === 'ready_for_approval' && !state.recoveryPending,
);
const decisionDisabled = !canDecide || decisionBusy || hydrateBusy;
// `hydrateBusy` only describes an in-flight background read. Once a card is rendered, the
// loaded projection remains the actionable snapshot until it explicitly reports
// `recoveryPending`; otherwise a refresh racing the first render can transiently disable an
// already-open dialog before the user has had a chance to submit it.
const decisionDisabled = !canDecide || decisionBusy;
const submitComment = () => {
// 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮
@@ -14,6 +14,13 @@ export type UiDesignStateSaveResult =
}
| { status: 'conflict'; current: UiDesignStateSnapshot };
export type UiDesignCodeGenerationResult = {
relativePath: string;
treeExports: string[];
treeCount: number;
nodeCount: number;
};
export type IUiDesignStateStore = {
load(assetId: string): Promise<UiDesignStateSnapshot>;
save(
@@ -21,6 +28,7 @@ export type IUiDesignStateStore = {
expectedRevision: number,
state: State,
): Promise<UiDesignStateSaveResult>;
generateCode(assetId: string): Promise<UiDesignCodeGenerationResult>;
};
export function createTauriUiDesignStateStore(
@@ -44,6 +52,11 @@ export function createTauriUiDesignStateStore(
},
});
},
generateCode(assetId) {
return invoke<UiDesignCodeGenerationResult>('generate_ui_design_code', {
input: { projectPath, expectedProjectId, assetId },
});
},
};
}
@@ -76,6 +89,9 @@ export const uiDesignStateStore: IUiDesignStateStore = {
committedProjectRevision: 0,
};
},
async generateCode() {
throw new Error('当前环境不支持生成代码');
},
};
export const EMPTY_UI_DESIGN_STATE = EMPTY_UI_EDITOR_STATE;
@@ -80,14 +80,22 @@ function fillClipPath(method: FillMethod, amount: number): string | undefined {
const sweep = amount * maxSweep;
const origin = config.origin;
const originAngle =
origin === 'Top' || origin === 'TopLeft' || origin === 'TopRight'
? 0
: origin === 'Right' || origin === 'BottomRight'
? 90
: origin === 'Bottom' || origin === 'BottomLeft'
? 180
: 270;
const start = config.clockwise ? originAngle : originAngle - sweep;
kind === 'Radial90'
? origin === 'TopLeft'
? 0
: origin === 'TopRight'
? 90
: origin === 'BottomRight'
? 180
: 270
: origin === 'Top'
? 0
: origin === 'Right'
? 90
: origin === 'Bottom'
? 180
: 270;
const start = originAngle - (config.clockwise ? 90 : sweep);
// CSS conic gradients provide a deterministic browser preview for radial
// fills. Exact engine parity is intentionally deferred.
return `conic-gradient(from ${start}deg, #000 0deg ${sweep}deg, transparent ${sweep}deg 360deg)`;
@@ -141,6 +149,7 @@ function imageTypeModel(
const size = logicalSize(sprite, multiplier);
if ('Tiled' in imageType) {
// TODO: Tiled.fill_center=false 当前暂不支持,保持浏览器 repeat fallback。
return {
kind: 'background',
src,
@@ -4,6 +4,32 @@ export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
export const AGC_CLIENT_MARKER_VALUE = 'agc';
/**
* Upper bound for the initial network transaction (DNS/connect/response
* headers). Callers may override this for a request that legitimately needs
* more time; the default prevents auth/bootstrap requests from hanging
* forever when the selected server or proxy is unavailable.
*/
export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000;
export class ClientHttpTimeoutError extends Error {
readonly code = 'CLIENT_HTTP_TIMEOUT';
readonly timeoutMs: number;
readonly url: string;
constructor(url: string, timeoutMs: number) {
super(`请求超时(${timeoutMs} ms):${url}`);
this.name = 'ClientHttpTimeoutError';
this.timeoutMs = timeoutMs;
this.url = url;
}
}
export function isClientHttpTimeoutError(
error: unknown,
): error is ClientHttpTimeoutError {
return error instanceof ClientHttpTimeoutError;
}
export type ClientServerPreset = 'release' | 'dev' | 'custom';
@@ -171,7 +197,11 @@ export function resolveClientHttpTarget(
export async function fetchClientHttp(
url: string,
init: RequestInit,
options: { serverBaseUrl?: string } = {},
options: {
serverBaseUrl?: string;
/** Set to null to opt out for a long-running request. */
timeoutMs?: number | null;
} = {},
): Promise<Response> {
const currentContext = currentClientHttpContext();
const serverBaseUrl = options.serverBaseUrl
@@ -186,8 +216,86 @@ export async function fetchClientHttp(
: { ...currentContext, serverBaseUrl },
);
const markedInit = withAgcClientMarker(init);
if (target.transport === 'tauri-http') {
return tauriHttpFetch(target.url, markedInit);
// Always use a private controller so an internal timeout cannot mutate a
// caller-owned AbortSignal. The caller's signal is still propagated in
// both directions, preserving normal AbortError behaviour for user aborts.
const timeoutMs =
options.timeoutMs === undefined
? CLIENT_HTTP_DEFAULT_TIMEOUT_MS
: options.timeoutMs;
if (timeoutMs === null) {
if (target.transport === 'tauri-http') {
return tauriHttpFetch(target.url, markedInit);
}
return fetch(target.url, markedInit);
}
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError('请求超时时间必须是大于 0 的有限数值');
}
const controller = new AbortController();
let timedOut = false;
const callerSignal = markedInit.signal;
const forwardCallerAbort = () => {
// AbortSignal.reason is available in modern browsers/Tauri WebViews. The
// fallback keeps compatibility with older runtimes and test doubles.
const reason = callerSignal?.reason;
try {
controller.abort(reason);
} catch {
controller.abort();
}
};
if (callerSignal) {
if (callerSignal.aborted) {
forwardCallerAbort();
} else {
callerSignal.addEventListener('abort', forwardCallerAbort, {
once: true,
});
}
}
const requestInit = { ...markedInit, signal: controller.signal };
let request: Promise<Response>;
try {
// Keep invocation synchronous so an already-aborted caller signal is
// observed by transports that only subscribe to `abort` events.
const responsePromise =
target.transport === 'tauri-http'
? tauriHttpFetch(target.url, requestInit)
: fetch(target.url, requestInit);
request = Promise.resolve(responsePromise);
} catch (error) {
callerSignal?.removeEventListener('abort', forwardCallerAbort);
throw error;
}
// A timed-out request is intentionally not awaited after the race settles,
// but transports may still reject when the abort reaches them. Attach a
// sink to avoid an unhandled rejection while keeping the original promise
// in the race for normal errors.
void request.catch(() => undefined);
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
timedOut = true;
controller.abort();
reject(new ClientHttpTimeoutError(target.url, timeoutMs));
}, timeoutMs);
});
try {
return await Promise.race([request, timeout]);
} catch (error) {
// Some transports reject with a generic error after AbortController.abort;
// expose a stable, actionable error to auth/bootstrap callers.
if (timedOut) {
throw new ClientHttpTimeoutError(target.url, timeoutMs);
}
throw error;
} finally {
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
callerSignal?.removeEventListener('abort', forwardCallerAbort);
}
return fetch(target.url, markedInit);
}
@@ -367,8 +367,6 @@ export function TransformEditor({
const warning =
transform.anchor_min[0] > transform.anchor_max[0] ||
transform.anchor_min[1] > transform.anchor_max[1] ||
transform.anchor_min.some((value) => value < 0 || value > 1) ||
transform.anchor_max.some((value) => value < 0 || value > 1) ||
geometry?.invalid;
return (
@@ -39,7 +39,7 @@ export function WorkflowActionCard({
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-xs font-semibold text-white disabled:cursor-wait disabled:opacity-60"
disabled={workflow.isAiRunning}
disabled={workflow.isBusy}
onClick={() => void action.action()}
>
{running ? action.runningLabel : hasRun ? '重新运行' : action.label}
@@ -48,7 +48,7 @@ export function WorkflowActionCard({
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-1.5 text-xs disabled:cursor-wait disabled:opacity-60"
disabled={workflow.isAiRunning}
disabled={workflow.isBusy}
onClick={workflow.continueToNextStep}
>
继续
@@ -1,5 +1,5 @@
import { ChevronLeft } from 'lucide-react';
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { ThemedModal } from '../../components/modal/ThemedModal';
import {
@@ -22,6 +22,12 @@ import {
useUiEditorSession,
} from './useUiEditorPage';
function saveAndGenerateLabel(isSaving: boolean, isGenerating: boolean) {
if (isSaving) return '保存中…';
if (isGenerating) return '生成中…';
return '保存并生成代码';
}
export default function UiEditorPage({
projectPath,
resourceId,
@@ -60,7 +66,21 @@ export default function UiEditorPage({
);
const [saveWarningOpen, setSaveWarningOpen] = useState(false);
const [saveAfterReturn, setSaveAfterReturn] = useState(false);
const [generateAfterWarning, setGenerateAfterWarning] = useState(false);
const [generateSuccess, setGenerateSuccess] = useState<string | null>(null);
const [returnConfirmOpen, setReturnConfirmOpen] = useState(false);
const saveDisabled =
session.save.isSaving ||
session.save.isGenerating ||
session.save.isLoading ||
session.workflow.isAiRunning ||
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked;
useEffect(() => {
if (session.save.isDirty) setGenerateSuccess(null);
}, [session.save.isDirty]);
async function save(afterReturn = false) {
if (await session.save.save()) {
@@ -71,11 +91,22 @@ export default function UiEditorPage({
}
}
async function saveAndGenerate() {
setGenerateSuccess(null);
const result = await session.save.saveAndGenerateCode();
if (result) {
setGenerateAfterWarning(false);
setGenerateSuccess(`代码已生成:${result.relativePath}`);
}
}
function requestSave(afterReturn = false) {
if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) {
return;
}
setGenerateSuccess(null);
setSaveAfterReturn(afterReturn);
setGenerateAfterWarning(false);
if (session.save.hasWarnings()) {
setSaveWarningOpen(true);
return;
@@ -83,6 +114,25 @@ export default function UiEditorPage({
void save(afterReturn);
}
function requestSaveAndGenerate() {
if (
!resourceId ||
session.save.isSaving ||
session.save.isGenerating ||
session.workflow.isAiRunning
) {
return;
}
setSaveAfterReturn(false);
setGenerateSuccess(null);
setGenerateAfterWarning(true);
if (session.save.hasWarnings()) {
setSaveWarningOpen(true);
return;
}
void saveAndGenerate();
}
function requestBack() {
if (session.save.isDirty) {
setReturnConfirmOpen(true);
@@ -111,17 +161,22 @@ export default function UiEditorPage({
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60"
disabled={
session.save.isSaving ||
session.save.isLoading ||
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked
}
disabled={saveDisabled}
onClick={() => requestSave()}
>
{session.save.isSaving ? '保存中…' : '保存'}
</button>
<button
type="button"
className="rounded-lg border border-orange-600 px-3 py-1.5 text-sm font-semibold text-orange-700 disabled:opacity-60"
disabled={saveDisabled}
onClick={requestSaveAndGenerate}
>
{saveAndGenerateLabel(
session.save.isSaving,
session.save.isGenerating,
)}
</button>
</div>
</header>
) : null}
@@ -133,10 +188,26 @@ export default function UiEditorPage({
<span>{session.save.saveError}</span>
</div>
) : null}
{session.save.generateError ? (
<div
className="mx-4 mt-3 flex shrink-0 items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800"
role="alert"
>
<span>代码生成失败:{session.save.generateError}</span>
</div>
) : null}
{generateSuccess ? (
<div
className="mx-4 mt-3 shrink-0 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800"
role="status"
>
{generateSuccess}
</div>
) : null}
<ToolNavigation
activeStep={session.workflow.activeStep}
furthestStepIndex={session.workflow.furthestStepIndex}
disabled={session.workflow.isAiRunning}
disabled={session.workflow.isBusy}
onChange={session.workflow.requestStepChange}
/>
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-[minmax(0,24fr)_minmax(0,44fr)_minmax(0,32fr)] overflow-hidden">
@@ -205,12 +276,7 @@ export default function UiEditorPage({
<button
type="button"
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
disabled={
session.save.isSaving ||
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked
}
disabled={saveDisabled}
onClick={() => requestSave(true)}
>
{session.save.isSaving ? '保存中…' : '保存并返回'}
@@ -273,10 +339,14 @@ export default function UiEditorPage({
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
onClick={() => {
setSaveWarningOpen(false);
void save(saveAfterReturn);
if (generateAfterWarning) {
void saveAndGenerate();
} else {
void save(saveAfterReturn);
}
}}
>
仍然保存
{generateAfterWarning ? '仍然保存并生成' : '仍然保存'}
</button>
</div>
</ThemedModal>
@@ -186,6 +186,8 @@ export function useUiEditorSession(
);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const normalizedInitialStepIndex =
initialStep === 'reference-analysis'
? 0
@@ -255,6 +257,8 @@ export function useUiEditorSession(
setPersistedRevision(0);
setSavedStateSignature(JSON.stringify(EMPTY_UI_EDITOR_STATE));
setSaveError(null);
setGenerateError(null);
setIsGenerating(false);
setHiddenNodeIds(new Set());
return;
}
@@ -263,6 +267,9 @@ export function useUiEditorSession(
setIsLoading(true);
setLoadError(null);
setPersistedRevision(null);
setSaveError(null);
setGenerateError(null);
setIsGenerating(false);
void stateStore
.load(resourceId)
.then(({ state, revision }) => {
@@ -365,6 +372,8 @@ export function useUiEditorSession(
!isSlaveToDescendant(images, id as UIDesignImageId, activeImageId),
);
const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging;
const isWorkflowBusy =
isAiRunning || isSaving || isGenerating || isLoading || editor.isLocked;
const stateSignature = JSON.stringify(editor.state);
const isDirty =
resourceId !== undefined &&
@@ -658,7 +667,7 @@ export function useUiEditorSession(
}
function requestStepChange(step: UiEditorStepId) {
if (step === activeStep || isAiRunning) return;
if (step === activeStep || isWorkflowBusy) return;
const leavingIssues = postCheckIssuesForStep(editor.state, activeStep);
const enteringIssues = prerequisiteIssuesForStep(editor.state, step);
if (leavingIssues.length === 0 && enteringIssues.length === 0) {
@@ -929,7 +938,7 @@ export function useUiEditorSession(
}
async function suggestUiDesignSemantics() {
if (isSuggesting) return;
if (isSuggesting || isWorkflowBusy) return;
setSuggestionStatus(null);
setIsSuggesting(true);
try {
@@ -952,7 +961,7 @@ export function useUiEditorSession(
}
async function recognizeUi() {
if (isRecognizing) return;
if (isRecognizing || isWorkflowBusy) return;
setRecognitionStatus(null);
setIsRecognizing(true);
try {
@@ -977,7 +986,7 @@ export function useUiEditorSession(
async function mergeUi() {
// TODO: This experimental operation is intentionally outside the formal workflow.
if (isMerging) return;
if (isMerging || isWorkflowBusy) return;
setMergeStatus(null);
setIsMerging(true);
try {
@@ -995,7 +1004,7 @@ export function useUiEditorSession(
}
async function bindComponents() {
if (isBinding) return;
if (isBinding || isWorkflowBusy) return;
setBindingStatus(null);
setIsBinding(true);
try {
@@ -1037,7 +1046,9 @@ export function useUiEditorSession(
if (
!resourceId ||
isSaving ||
isGenerating ||
isLoading ||
isAiRunning ||
loadError ||
persistedRevision === null ||
editor.isLocked
@@ -1045,24 +1056,26 @@ export function useUiEditorSession(
return false;
}
setSaveError(null);
setGenerateError(null);
setIsSaving(true);
const snapshot = structuredClone(editor.state);
const snapshotSignature = JSON.stringify(snapshot);
try {
const result = await stateStore.save(
resourceId,
persistedRevision,
snapshot,
);
if (result.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。');
return false;
}
setPersistedRevision(result.revision);
if (JSON.stringify(editor.state) === snapshotSignature) {
setSavedStateSignature(snapshotSignature);
}
return true;
return await editor.runWithStateLocked(async (snapshot) => {
const snapshotSignature = JSON.stringify(snapshot);
const result = await stateStore.save(
resourceId,
persistedRevision,
snapshot,
);
if (result.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。');
return false;
}
setPersistedRevision(result.revision);
if (JSON.stringify(editor.state) === snapshotSignature) {
setSavedStateSignature(snapshotSignature);
}
return true;
});
} catch {
setSaveError('保存失败,请稍后重试。');
return false;
@@ -1071,6 +1084,78 @@ export function useUiEditorSession(
}
}
async function generateCode() {
if (
!resourceId ||
isGenerating ||
isSaving ||
isLoading ||
isAiRunning ||
loadError ||
persistedRevision === null ||
editor.isLocked
) {
setGenerateError('当前状态不允许生成代码,请稍后重试。');
return null;
}
setGenerateError(null);
setIsGenerating(true);
try {
return await editor.runWithStateLocked(() =>
stateStore.generateCode(resourceId),
);
} catch (cause) {
setGenerateError(cause instanceof Error ? cause.message : String(cause));
return null;
} finally {
setIsGenerating(false);
}
}
async function saveAndGenerateCode() {
if (
!resourceId ||
isSaving ||
isGenerating ||
isLoading ||
isAiRunning ||
loadError ||
persistedRevision === null ||
editor.isLocked
) {
return null;
}
setSaveError(null);
setGenerateError(null);
setIsSaving(true);
setIsGenerating(true);
try {
return await editor.runWithStateLocked(async (snapshot) => {
const snapshotSignature = JSON.stringify(snapshot);
const saved = await stateStore.save(
resourceId,
persistedRevision,
snapshot,
);
if (saved.status === 'conflict') {
setSaveError('资源已在别处更新;请重新加载后再保存。');
return null;
}
setPersistedRevision(saved.revision);
if (JSON.stringify(editor.state) === snapshotSignature) {
setSavedStateSignature(snapshotSignature);
}
return await stateStore.generateCode(resourceId);
});
} catch (cause) {
setGenerateError(cause instanceof Error ? cause.message : String(cause));
return null;
} finally {
setIsGenerating(false);
setIsSaving(false);
}
}
return {
input: {
projectPath,
@@ -1197,6 +1282,7 @@ export function useUiEditorSession(
furthestStepIndex,
nextStep,
isAiRunning,
isBusy: isWorkflowBusy,
pendingStepChange: pendingWorkflowStepChange,
isSuggesting,
hasSuggested,
@@ -1237,9 +1323,13 @@ export function useUiEditorSession(
isSaving,
isDirty,
saveError,
isGenerating,
generateError,
hasWarnings: () => postCheckIssuesForSave(editor.state).length > 0,
warnings: () => postCheckIssuesForSave(editor.state),
save,
generateCode,
saveAndGenerateCode,
},
};
}