Compare commits

...

3 Commits

Author SHA1 Message Date
134
50f34e7a0f fix: keep sent Claw messages out of composer 2026-07-27 10:22:03 +08:00
134
c889bfc381 feat: add product planning v2 claw skill 2026-07-24 14:10:53 +08:00
134
62c686a26b fix: fail over on upstream image provider errors 2026-07-24 13:51:35 +08:00
8 changed files with 208 additions and 65 deletions

View File

@ -15,7 +15,6 @@ import {
} from "@/lib/openclaw-chat/config";
import {
buildClawdCancelKey,
cancelClawdRequest,
clearClawdRequestCancellation,
isClawdRequestCanceled,
registerActiveClawdRequest,
@ -259,10 +258,6 @@ export async function POST(request: Request) {
const controller = new AbortController();
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
const handleRequestAbort = () => {
cancelClawdRequest(cancelKey);
};
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
const response = await fetch(endpoint, {
method: "POST",
headers: {
@ -302,7 +297,6 @@ export async function POST(request: Request) {
signal: controller.signal,
}).finally(() => {
clearTimeout(timeout);
request.signal.removeEventListener("abort", handleRequestAbort);
unregisterActiveClawdRequest(cancelKey);
});

View File

@ -101,6 +101,10 @@ import {
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import {
isClawdTransportInterruption,
shouldRestoreClawdComposerAfterFailure,
} from "@/lib/openclaw-chat/request-lifecycle";
import {
isCanvasImageReadyForAi,
isCanvasImageUploadPending,
@ -11199,8 +11203,7 @@ export function CanvasChatPanel({
});
const responseData = await response.json().catch(() => null);
if (response.status === 499 || responseData?.canceled === true) {
canceledClawdMessageIdsRef.current.add(userMessageId);
throw new DOMException("Claw request canceled", "AbortError");
throw new Error("Claw request interrupted");
}
if (!response.ok) {
console.error("[CanvasChat] Claw API returned error:", {
@ -11251,10 +11254,13 @@ export function CanvasChatPanel({
void mutateClawdHistory();
hasHydratedHistoryRef.current = true;
} catch (error) {
const wasCanceled =
canceledClawdMessageIdsRef.current.has(userMessageId) ||
(error instanceof Error && error.name === "AbortError");
if (wasCanceled) {
const explicitlyCanceled =
canceledClawdMessageIdsRef.current.has(userMessageId);
if (
shouldRestoreClawdComposerAfterFailure({
explicitlyCanceled,
})
) {
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
pendingClawdRequestRestoreRef.current = null;
restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot);
@ -11263,44 +11269,11 @@ export function CanvasChatPanel({
return;
}
console.error("[CanvasChat] Claw request failed:", error);
pendingClawdSubmissionRef.current = null;
if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
recentClawdSubmissionRef.current = null;
writeStoredClawdComposerSubmissionSnapshot(null);
}
setMessages((previousMessages) =>
previousMessages.filter((message) => message.id !== userMessageId)
toast.error(
isClawdTransportInterruption(error)
? "连接已中断,任务仍可能在后台处理,请稍后查看 Claw 历史"
: CLAW_UPGRADE_RETRY_MESSAGE
);
toast.error(CLAW_UPGRADE_RETRY_MESSAGE);
if (!isProgrammaticClawdQuickSend) {
setClawdTableFiles(tableFilesSnapshot);
setAttachments(attachmentsSnapshot);
if (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt) {
setSelectedSkillTagId(clawdSkillTagForRequest.id);
pendingSelectedClawdSkillTagRef.current = clawdSkillTagForRequest;
}
const editableDiv = textareaRef.current as HTMLDivElement | null;
const currentEditorText = (editableDiv?.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!currentEditorText) {
setInput(textContent);
if (editableDiv) {
editableDiv.textContent = textContent;
window.setTimeout(() => {
attachmentsSnapshot.forEach((attachment, index) => {
if (!attachment.url) return;
insertImageTagInEditor(
attachment.url,
normalizeChatAttachmentName(attachment.name),
index
);
});
}, 0);
}
}
}
} finally {
pendingClawdSubmissionRef.current = null;
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {

View File

@ -716,12 +716,11 @@ async function sendGrsaiGptImageRequest(params: {
response.status,
responseText.substring(0, 1000)
);
const providerError = getGrsaiErrorMessage(data);
const errorMessage =
data?.error?.message ||
data?.message ||
data?.error ||
responseText ||
`${response.status} ${response.statusText}`;
providerError !== "未知错误"
? providerError
: responseText || `${response.status} ${response.statusText}`;
throw new Error(`图片生成失败: ${errorMessage}`);
}
@ -754,6 +753,13 @@ async function sendGrsaiGptImageRequest(params: {
timeoutLabel: "Grsai GPT Image",
});
}
const providerError = getGrsaiErrorMessage(data);
throw new Error(
`图片生成失败: 未返回任务 ID 或可用图片${
providerError !== "未知错误" ? `: ${providerError}` : ""
}`
);
}
return data;
@ -857,18 +863,46 @@ function getGrsaiProgress(payload: any): number | null {
function getGrsaiErrorMessage(payload: any): string {
const normalized = normalizeGrsaiPayload(payload);
const normalizePart = (part: unknown) => {
if (typeof part === "string" || typeof part === "number") {
return String(part).trim();
}
if (!isRecord(part)) return "";
return String(
part.message ||
part.msg ||
part.detail ||
part.reason ||
part.code ||
part.type ||
""
).trim();
};
const messages = [
normalized?.failure_reason,
normalized?.error?.message,
normalized?.error?.msg,
normalized?.error?.code,
normalized?.error,
normalized?.message,
normalized?.msg,
normalized?.detail,
normalized?.reason,
normalized?.code,
payload?.error?.message,
payload?.error?.msg,
payload?.error?.code,
payload?.msg,
payload?.message,
payload?.error,
payload?.detail,
payload?.reason,
payload?.code,
]
.map((part) => String(part || "").trim())
.map(normalizePart)
.filter(Boolean);
return messages.join(" / ") || "未知错误";
return Array.from(new Set(messages)).join(" / ") || "未知错误";
}
function hasGrsaiImages(payload: any): boolean {
@ -899,18 +933,36 @@ async function fetchWithTimeout(
async function readJsonResponse(response: Response, label: string): Promise<any> {
const text = await response.text();
const payload = parseGrsaiJsonOrStreamResponse(text);
if (!response.ok) {
logger.error(`${label} failed:`, response.status, text.substring(0, 500));
throw new Error(`${label} 接口错误:${response.status} ${response.statusText}`);
const providerError = getGrsaiErrorMessage(payload);
const detail =
providerError !== "未知错误"
? providerError
: String(payload?.raw || text || "").trim().slice(0, 300);
throw new Error(
`${label} 接口错误:${response.status} ${response.statusText}${
detail ? `: ${detail}` : ""
}`
);
}
try {
return JSON.parse(text);
} catch (error) {
if (isRecord(payload) && !("raw" in payload)) {
return payload;
}
if (!isRecord(payload)) {
return payload;
}
if ("raw" in payload) {
logger.error(`${label} returned non-JSON response:`, text.substring(0, 500));
throw new Error(`${label} 返回格式异常,未能解析任务结果`);
}
return payload;
}
async function pollGrsaiNanoBananaResult(params: {
@ -1047,11 +1099,16 @@ async function submitAndPollGrsaiNanoBanana(params: {
const taskId = getGrsaiTaskId(submitPayload);
if (!taskId) {
const providerError = getGrsaiErrorMessage(submitPayload);
logger.error(
`${params.apiLogPrefix} Async submit response has no task id:`,
JSON.stringify(submitPayload).substring(0, 500)
);
throw new Error("GRSAI Nano Banana 异步任务提交失败:未返回任务 ID");
throw new Error(
`GRSAI Nano Banana 异步任务提交失败:未返回任务 ID${
providerError !== "未知错误" ? `: ${providerError}` : ""
}`
);
}
logger.debug(`${params.apiLogPrefix} Async task submitted:`, { taskId });

View File

@ -151,7 +151,10 @@ export function classifyAiModelHealthFailure(error: unknown): {
if (
normalized.includes("insufficient balance") ||
normalized.includes("insufficient credit") ||
normalized.includes("credits insufficient") ||
normalized.includes("credit insufficient") ||
normalized.includes("not enough credit") ||
normalized.includes("quota") ||
normalized.includes("余额") ||
normalized.includes("额度") ||
@ -209,10 +212,38 @@ export function classifyAiModelHealthFailure(error: unknown): {
};
}
if (
normalized.includes("未返回任务 id") ||
normalized.includes("未返回任务id") ||
normalized.includes("no task id") ||
normalized.includes("missing task id") ||
normalized.includes("provider error") ||
normalized.includes("provider_error") ||
normalized.includes("provider request failed") ||
normalized.includes("provider task failed") ||
normalized.includes("upstream error") ||
normalized.includes("upstream_error") ||
normalized.includes("upstream request failed") ||
normalized.includes("上游服务错误") ||
normalized.includes("上游接口错误")
) {
return {
eventType: "unavailable",
severity: "critical",
counted: true,
reason: "provider_execution_failed",
};
}
if (
normalized.includes("download") ||
normalized.includes("no image url") ||
normalized.includes("no image") ||
normalized.includes("empty result") ||
normalized.includes("empty response") ||
normalized.includes("空结果") ||
normalized.includes("返回结果为空") ||
normalized.includes("响应为空") ||
normalized.includes("未返回可用图片") ||
normalized.includes("未包含可用图片") ||
normalized.includes("未解析到图片") ||
@ -245,14 +276,21 @@ export function shouldFailoverAiModelError(error: unknown) {
normalized.includes("snapgen 任务已完成但未返回图片地址") ||
normalized.includes("snapgen 未返回可用图片");
if (imageProviderTaskFailed) {
return true;
}
const providerExecutionFailed =
classification.reason === "provider_execution_failed" ||
normalized.includes("未返回任务 id") ||
normalized.includes("未返回任务id") ||
normalized.includes("no task id") ||
normalized.includes("missing task id");
if (classification.reason === "user_cancelled" || classification.reason === "non_provider_failure") {
return false;
}
if (imageProviderTaskFailed || providerExecutionFailed) {
return true;
}
if (
classification.eventType === "timeout" ||
classification.reason === "transport_failure_no_provider_response"
@ -263,6 +301,11 @@ export function shouldFailoverAiModelError(error: unknown) {
if (classification.eventType === "download_error") {
return (
normalized.includes("no image") ||
normalized.includes("empty result") ||
normalized.includes("empty response") ||
normalized.includes("空结果") ||
normalized.includes("返回结果为空") ||
normalized.includes("响应为空") ||
normalized.includes("未返回可用图片") ||
normalized.includes("未包含可用图片") ||
normalized.includes("未解析到图片") ||

View File

@ -1,6 +1,6 @@
export const CANVAS_SKILL_TAGS_SETTING_KEY = "canvas_skill_tags";
export const CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX = "canvas_skill_tag_order:";
export const CANVAS_SKILL_TAGS_CONFIG_VERSION = 4;
export const CANVAS_SKILL_TAGS_CONFIG_VERSION = 5;
export const DEFAULT_CANVAS_SKILL_TAGS_TITLE = "Claw 快捷技能";
export type CanvasSkillTagSettings = {
@ -44,6 +44,14 @@ export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
prompt:
"【技能路由:product-asset-api】用途:查询商品详情页、主图、SKU 图、白底图下载链接。触发关键词:详情页、详情页面、详情素材、详情整套、任务详情、SKU图、主图、白底图、白底、SKU:、SPU:、货号 + 详情/图片/白底需求。必须要求用户提供 SKU 或 SPU;提到白底/白底图时查白底图,其他详情/主图/SKU图查任务详情素材。输出下载链接,不默认展开图片预览;命中多组候选时默认展示最新/最相关 1 组,并提示可展开全部详情链接;未命中时说明 SKU/SPU 可能不正确或素材未入库。",
},
{
id: "product-detail-planning-v2",
name: "商品策划案 V2",
iconName: "sparkles",
placeholder: "输入 SKU/SPU、产品名或上传产品图,开始商品策划案 V2",
prompt:
"【技能路由:product-detail-planning-v2】用途:通过可恢复的分步对话完成商品详情页全案策划,覆盖产品事实、竞品、真实评价与问大家、内容策划、视觉策划、详情页提示词、五张主图、主图视频、AI 买家秀,以及最终 HTML 和 17 子表 Excel 交付。触发关键词:商品策划案、详情页策划、完整详情策划、新品详情策划、竞品详情分析、详情页改版、主图策划、SKU图策划、主图视频策划、AI买家秀策划、继续详情策划、新版商品策划案、商品策划案V2。通用“商品策划案”默认进入 V2,只有用户明确要求旧版/V1时才使用兼容流程。公司已有商品可提供 SKU/SPU;新品可以提供产品名、现有资料并上传产品图。每轮只处理当前节点,完整展示当前节点数据、系统最优推荐和可选动作,但不得替用户确认或跨节点推进;图片只证明可见结构,不得自动证明材质、参数、检测或服务承诺。全部必需节点完成后生成 HTML 策划案和固定 17 子表 Excel,并返回查看或下载入口。",
},
{
id: "install-media-db",
name: "安装资料",

View File

@ -0,0 +1,16 @@
export function shouldRestoreClawdComposerAfterFailure({
explicitlyCanceled,
}: {
explicitlyCanceled: boolean;
}) {
return explicitlyCanceled;
}
export function isClawdTransportInterruption(error: unknown) {
if (!(error instanceof Error)) return false;
return (
error.name === "AbortError" ||
/aborted|failed to fetch|fetch failed|network|interrupted/i.test(error.message)
);
}

View File

@ -13,6 +13,7 @@ export default defineConfig({
/routes\/canvas-scale-adjust-intent\.test\.ts/,
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
/routes\/model-classification\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/,
],
fullyParallel: true,
forbidOnly: !!process.env.CI,

View File

@ -0,0 +1,51 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import {
isClawdTransportInterruption,
shouldRestoreClawdComposerAfterFailure,
} from "@/lib/openclaw-chat/request-lifecycle";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
test.describe("Claw request lifecycle", () => {
test("restores the composer only after an explicit user cancellation", () => {
expect(
shouldRestoreClawdComposerAfterFailure({ explicitlyCanceled: true })
).toBe(true);
expect(
shouldRestoreClawdComposerAfterFailure({ explicitlyCanceled: false })
).toBe(false);
});
test("recognizes browser and network interruptions without treating them as edits", () => {
expect(
isClawdTransportInterruption(
new DOMException("The operation was aborted", "AbortError")
)
).toBe(true);
expect(isClawdTransportInterruption(new Error("fetch failed"))).toBe(true);
expect(isClawdTransportInterruption(new Error("provider unavailable"))).toBe(
false
);
});
test("does not cancel the upstream request when the browser connection closes", () => {
const source = readSource("app/api/canvas/clawd-chat/route.ts");
expect(source).not.toContain('request.signal.addEventListener("abort"');
expect(source).not.toContain("cancelClawdRequest(cancelKey)");
expect(source).toContain("registerActiveClawdRequest(cancelKey");
});
test("does not classify every AbortError as an explicit stop action", () => {
const source = readSource("components/canvas/CanvasChatPanel.tsx");
expect(source).toContain("shouldRestoreClawdComposerAfterFailure");
expect(source).not.toContain(
'(error instanceof Error && error.name === "AbortError")'
);
});
});