Compare commits
3 Commits
3096eb6752
...
50f34e7a0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f34e7a0f | ||
|
|
c889bfc381 | ||
|
|
62c686a26b |
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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("未解析到图片") ||
|
||||
|
||||
@ -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: "安装资料",
|
||||
|
||||
16
lib/openclaw-chat/request-lifecycle.ts
Normal file
16
lib/openclaw-chat/request-lifecycle.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
51
tests/routes/clawd-request-lifecycle.test.ts
Normal file
51
tests/routes/clawd-request-lifecycle.test.ts
Normal 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")'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user