mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
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
130 lines
4.1 KiB
TypeScript
130 lines
4.1 KiB
TypeScript
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);
|
||
}
|