fix(design): preserve command outcome certainty
This commit is contained in:
@@ -112,14 +112,23 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
let backendCode: string | undefined;
|
||||
let commandOutcome: string | undefined;
|
||||
try {
|
||||
const payload = await response.json() as { error?: string; code?: unknown };
|
||||
const payload = await response.json() as {
|
||||
error?: string;
|
||||
code?: unknown;
|
||||
commandOutcome?: unknown;
|
||||
};
|
||||
if (payload?.error) {
|
||||
message = payload.error;
|
||||
}
|
||||
if (typeof payload?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/u.test(payload.code)) {
|
||||
backendCode = payload.code;
|
||||
}
|
||||
if (payload?.commandOutcome === 'definitive_failure'
|
||||
|| payload?.commandOutcome === 'unknown') {
|
||||
commandOutcome = payload.commandOutcome;
|
||||
}
|
||||
} catch {
|
||||
// ignore body parse failure
|
||||
}
|
||||
@@ -127,6 +136,7 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
||||
source: 'browser-fallback',
|
||||
status: response.status,
|
||||
...(backendCode ? { backendCode } : {}),
|
||||
...(commandOutcome ? { commandOutcome } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +165,9 @@ function createProxyHttpError(data: HostApiProxyData): Error {
|
||||
return normalizeAppError(new Error(message), {
|
||||
status: data.status,
|
||||
...(payload && typeof payload.code === 'string' ? { backendCode: payload.code } : {}),
|
||||
...(payload?.commandOutcome === 'definitive_failure' || payload?.commandOutcome === 'unknown'
|
||||
? { commandOutcome: payload.commandOutcome }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
designAssetDownloadPath,
|
||||
type DesignAsset,
|
||||
type DesignAssetSaveResult,
|
||||
type DesignCommandFailureOutcome,
|
||||
type DesignCommandInput,
|
||||
type DesignCommandResult,
|
||||
type DesignDeleteWorkspaceResult,
|
||||
@@ -24,6 +25,7 @@ type ImageWorkspaceEnvelope<T> = {
|
||||
status?: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
commandOutcome?: DesignCommandFailureOutcome;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
@@ -32,12 +34,19 @@ export const IMAGE_WORKSPACE_CREATE_PROJECT_EVENT = 'niancode:image-workspace:cr
|
||||
export class ImageWorkspaceApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly commandOutcome?: DesignCommandFailureOutcome;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
constructor(
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
commandOutcome?: DesignCommandFailureOutcome,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ImageWorkspaceApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.commandOutcome = commandOutcome;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +75,11 @@ function getErrorCode(error: unknown, status: number): string {
|
||||
return 'IMAGE_WORKSPACE_REQUEST_FAILED';
|
||||
}
|
||||
|
||||
function getCommandOutcome(error: unknown): DesignCommandFailureOutcome | undefined {
|
||||
const outcome = error instanceof AppError ? error.details?.commandOutcome : undefined;
|
||||
return outcome === 'definitive_failure' || outcome === 'unknown' ? outcome : undefined;
|
||||
}
|
||||
|
||||
async function requestData<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
let response: ImageWorkspaceEnvelope<T>;
|
||||
try {
|
||||
@@ -76,6 +90,7 @@ async function requestData<T>(path: string, init: RequestInit = {}): Promise<T>
|
||||
status,
|
||||
getErrorCode(error, status),
|
||||
error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
|
||||
getCommandOutcome(error),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,6 +99,7 @@ async function requestData<T>(path: string, init: RequestInit = {}): Promise<T>
|
||||
response.status ?? 502,
|
||||
response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED',
|
||||
response.error ?? 'AI 设计请求失败',
|
||||
response.commandOutcome,
|
||||
);
|
||||
}
|
||||
return response.data;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type { DesignWorkspace } from '../../../shared/image-workspace';
|
||||
@@ -14,6 +15,32 @@ const STARTER_MESSAGES = [
|
||||
'做一张社团活动海报',
|
||||
];
|
||||
|
||||
function chatFailureMessage(error: unknown): string {
|
||||
if (error instanceof ImageWorkspaceApiError) {
|
||||
if (error.commandOutcome === 'unknown') {
|
||||
return error.status === 401 || error.status === 403
|
||||
? '登录已过期,这条消息的结果还没确认;重新登录后请继续原操作'
|
||||
: '暂时没能确认这条消息的处理结果,内容还在输入框里';
|
||||
}
|
||||
const messages: Record<string, string> = {
|
||||
design_reasoner_invalid: 'AI 没有整理好这次想法,请再试一次',
|
||||
design_reasoner_unavailable: 'AI 现在有点忙,暂时没能整理这次想法,请稍后再试',
|
||||
design_agent_run_failed: 'AI 这次没有整理好,内容还在输入框里,请再试一次',
|
||||
design_agent_run_cancelled: '这次整理已停止,内容还在输入框里',
|
||||
design_revision_conflict: '设计内容刚刚更新了,请再发送一次',
|
||||
design_direction_revision_conflict: '设计内容刚刚更新了,请再发送一次',
|
||||
auth_required: '请先登录,再继续创作',
|
||||
auth_expired: '登录已过期,请重新登录后继续创作',
|
||||
};
|
||||
const message = messages[error.code.toLowerCase()];
|
||||
if (message) return message;
|
||||
if (error.commandOutcome === 'definitive_failure') {
|
||||
return 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次';
|
||||
}
|
||||
}
|
||||
return '暂时没能确认这条消息的处理结果,内容还在输入框里';
|
||||
}
|
||||
|
||||
export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) {
|
||||
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
|
||||
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
|
||||
@@ -43,8 +70,8 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
|
||||
const submit = () => {
|
||||
if (!chatDraft.trim() || submittingChat) return;
|
||||
void sendChat().catch(() => {
|
||||
toast.error('消息没有发出去,请再试一次');
|
||||
void sendChat().catch((error) => {
|
||||
toast.error(chatFailureMessage(error));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -137,9 +137,11 @@ function isUnavailableError(error: unknown): boolean {
|
||||
|
||||
function isDirectionConflict(error: unknown): boolean {
|
||||
return error instanceof ImageWorkspaceApiError
|
||||
&& (error.status === 409
|
||||
|| error.code === 'DESIGN_DIRECTION_REVISION_CONFLICT'
|
||||
|| error.code === 'DIRECTION_REVISION_CONFLICT');
|
||||
&& [
|
||||
'design_revision_conflict',
|
||||
'design_direction_revision_conflict',
|
||||
'direction_revision_conflict',
|
||||
].includes(error.code.toLowerCase());
|
||||
}
|
||||
|
||||
function isQuoteBlocked(error: unknown): boolean {
|
||||
@@ -147,6 +149,15 @@ function isQuoteBlocked(error: unknown): boolean {
|
||||
&& error.code.toLowerCase() === 'design_quote_blocked';
|
||||
}
|
||||
|
||||
function isDefinitiveCommandFailure(error: unknown): error is ImageWorkspaceApiError {
|
||||
return error instanceof ImageWorkspaceApiError
|
||||
&& error.commandOutcome === 'definitive_failure';
|
||||
}
|
||||
|
||||
function isUnknownCommandOutcome(error: unknown): boolean {
|
||||
return error instanceof ImageWorkspaceApiError && error.commandOutcome === 'unknown';
|
||||
}
|
||||
|
||||
function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null {
|
||||
const data = (event as MessageEvent<unknown>).data;
|
||||
if (typeof data !== 'string') return null;
|
||||
@@ -299,7 +310,16 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
return result.workspace;
|
||||
} catch (error) {
|
||||
const message = messageForError(error);
|
||||
if (isDirectionConflict(error)) {
|
||||
if (isUnknownCommandOutcome(error)) {
|
||||
set((state) => ({
|
||||
pendingOperations: {
|
||||
...state.pendingOperations,
|
||||
[operation.id]: { ...operation, status: 'unknown', error: message },
|
||||
},
|
||||
...(isAuthError(error) ? { status: 'auth-required' as const } : {}),
|
||||
error: '操作结果尚未确认,可使用同一操作标识安全重试',
|
||||
}));
|
||||
} else if (isDirectionConflict(error)) {
|
||||
set((state) => {
|
||||
const pendingOperations = { ...state.pendingOperations };
|
||||
delete pendingOperations[operation.id];
|
||||
@@ -318,6 +338,15 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
delete pendingOperations[operation.id];
|
||||
return { pendingOperations, error: null };
|
||||
});
|
||||
} else if (isDefinitiveCommandFailure(error)) {
|
||||
set((state) => {
|
||||
const pendingOperations = { ...state.pendingOperations };
|
||||
delete pendingOperations[operation.id];
|
||||
const assistantStreams = { ...state.assistantStreams };
|
||||
delete assistantStreams[operation.id];
|
||||
return { pendingOperations, assistantStreams, error: message };
|
||||
});
|
||||
if (error.status === 409) await get().refreshWorkspace().catch(() => null);
|
||||
} else {
|
||||
set((state) => ({
|
||||
pendingOperations: {
|
||||
|
||||
Reference in New Issue
Block a user