Files
cc-switch/src/lib/errors/skillErrorParser.ts
T
Jason 83f0155f3c fix(skills): spool repo archives to disk and lift the 128 MiB download cap
Skill discovery, install, check-updates and update all download the
whole GitHub repo ZIP. The compressed body was collected into a Vec in
memory and capped at 128 MiB, so repos whose docs/ images dwarf the
skills themselves (K-Dense-AI/scientific-agent-skills: 235 MiB ZIP,
277 MiB uncompressed, 20 MiB of skills) failed before extraction even
though they fit the 512 MiB extraction budget and 30k entry cap. The
download cap also reused ARCHIVE_TOO_LARGE, so the UI blamed the
extraction limit.

Stream the body chunk by chunk into an anonymous temp file (deleted on
drop, including error paths), still counting actually received bytes,
and open the ZipArchive from that file. With memory no longer the
constraint, align the download cap with the extraction budget, since a
larger compressed body could not be extracted anyway. Report the
download cap under its own ARCHIVE_DOWNLOAD_TOO_LARGE code with
four-locale text. Extraction budget, entry cap and path/symlink
protections are unchanged.

Refs #8421
2026-10-16 19:29:24 +08:00

130 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TFunction } from "i18next";
/**
* 结构化错误对象
*/
export interface SkillError {
code: string;
context: Record<string, string>;
suggestion?: string;
}
/**
* 尝试解析后端返回的错误字符串
* 如果是 JSON 格式,返回结构化错误;否则返回 null
*/
export function parseSkillError(errorString: string): SkillError | null {
try {
const parsed = JSON.parse(errorString);
if (parsed.code && parsed.context) {
return parsed as SkillError;
}
} catch {
// 不是 JSON 格式,返回 null
}
return null;
}
/**
* 将错误码映射到 i18n key
*/
function getErrorI18nKey(code: string): string {
const mapping: Record<string, string> = {
SKILL_NOT_FOUND: "skills.error.skillNotFound",
MISSING_REPO_INFO: "skills.error.missingRepoInfo",
DOWNLOAD_TIMEOUT: "skills.error.downloadTimeout",
DOWNLOAD_FAILED: "skills.error.downloadFailed",
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
EMPTY_ARCHIVE: "skills.error.emptyArchive",
INVALID_REPO_REF: "skills.error.invalidRepoRef",
ARCHIVE_TOO_LARGE: "skills.error.archiveTooLarge",
ARCHIVE_DOWNLOAD_TOO_LARGE: "skills.error.archiveDownloadTooLarge",
ARCHIVE_TOO_MANY_ENTRIES: "skills.error.archiveTooManyEntries",
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip",
};
return mapping[code] || "skills.error.unknownError";
}
/**
* 将建议码映射到 i18n key
*/
function getSuggestionI18nKey(suggestion: string): string {
const mapping: Record<string, string> = {
checkNetwork: "skills.error.suggestion.checkNetwork",
checkProxy: "skills.error.suggestion.checkProxy",
retryLater: "skills.error.suggestion.retryLater",
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
checkPermission: "skills.error.suggestion.checkPermission",
uninstallFirst: "skills.error.suggestion.uninstallFirst",
checkZipContent: "skills.error.suggestion.checkZipContent",
http403: "skills.error.http403",
http404: "skills.error.http404",
http429: "skills.error.http429",
};
return mapping[suggestion] || suggestion;
}
/**
* 格式化技能错误为用户友好的消息
* @param errorString 后端返回的错误字符串
* @param t i18next 翻译函数
* @param defaultTitle 默认标题的 i18n key(如 "skills.installFailed")
* @returns 包含标题和描述的对象
*/
export function formatSkillError(
errorString: string,
t: TFunction,
defaultTitle: string = "skills.installFailed",
): { title: string; description: string } {
const parsedError = parseSkillError(errorString);
if (!parsedError) {
// 如果不是结构化错误,返回原始错误字符串
return {
title: t(defaultTitle),
description: errorString || t("common.error"),
};
}
const { code, context, suggestion } = parsedError;
// 获取错误消息的 i18n key
const errorKey = getErrorI18nKey(code);
// 构建描述(错误消息 + 建议)
let description = t(errorKey, context);
// 如果有建议,追加到描述中
if (suggestion) {
const suggestionKey = getSuggestionI18nKey(suggestion);
const suggestionText = t(suggestionKey);
description += `\n\n${suggestionText}`;
}
return {
title: t(defaultTitle),
description,
};
}
/**
* 一句话的失败原因,放进「N 个仓库没有读到:owner/name(原因)」这类横幅。
* HTTP 403 / 404 / 429 用对应的说明(如「GitHub 访问受限,可能是请求频率过高」),
* 其余结构化错误用错误码的文案,非结构化错误取原文第一行。
*/
export function skillErrorReason(errorString: string, t: TFunction): string {
const parsedError = parseSkillError(errorString);
if (!parsedError) {
return (errorString.split("\n")[0] ?? "").trim() || t("common.error");
}
const { code, context, suggestion } = parsedError;
if (suggestion && /^http\d{3}$/.test(suggestion)) {
return t(getSuggestionI18nKey(suggestion));
}
return t(getErrorI18nKey(code), context);
}