This commit is contained in:
董宏凯 committed 2026-08-27 16:33:33 +08:00
commit 440b490b0d
22 files changed
+1051 -190

No files matched your search

@@ -165,10 +165,26 @@ export function SkillPickerSheet({
return;
}
try {
const validation = await validateUpload.mutateAsync(file);
const validation = await validateUpload.mutateAsync({
file,
targetCategory: "custom",
});
if (validation.conflict) {
const owner = validation.owner_username ?? validation.owner_user_id;
if (validation.owned_by_other && owner) {
toast.error(
`技能「${validation.skill_name}」已由用户「${owner}」上传,不能覆盖。`,
);
return;
}
if (validation.owned_by_me && validation.can_overwrite) {
toast.warning(
`您已上传过同名技能「${validation.skill_name}」,可到「技能」设置页重复上传并覆盖原版本。`,
);
return;
}
toast.warning(
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
`已存在同名技能「${validation.skill_name}」,当前不可覆盖。`,
);
return;
}
@@ -1201,7 +1201,24 @@ function InstallSkillDialog({
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
const validateFile = useCallback(
async (selected: File, category: "custom" | "public") => {
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync({
file: selected,
targetCategory: category,
});
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[validateMutation],
);
const reset = useCallback(() => {
setFile(null);
@@ -1240,19 +1257,12 @@ function InstallSkillDialog({
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error(dialog.rejectInvalidExt);
return;
}
setFile(selected);
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync(selected);
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[dialog.rejectInvalidExt, validateMutation],
);
}
setFile(selected);
await validateFile(selected, targetCategory);
},
[dialog.rejectInvalidExt, targetCategory, validateFile],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
@@ -1329,12 +1339,29 @@ function InstallSkillDialog({
}
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
const customConflictMessage = !validation
? ""
: otherCustomConflict
? dialog.conflictOwnedByOther
.replace("{name}", validation.skill_name)
.replace(
"{owner}",
validation.owner_username ?? validation.owner_user_id ?? "—",
)
: validation.owned_by_me
? (ownCustomCanOverwrite
? dialog.conflictOwnedByMe
: dialog.conflictOwnedByMeWrongTarget
).replace("{name}", validation.skill_name)
: dialog.conflictCustom.replace("{name}", validation.skill_name);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
@@ -1453,9 +1480,12 @@ function InstallSkillDialog({
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => setTargetCategory(cat)}
value={cat}
checked={targetCategory === cat}
onChange={() => {
setTargetCategory(cat);
if (file) void validateFile(file, cat);
}}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
@@ -1513,15 +1543,18 @@ function InstallSkillDialog({
</ul>
</Field>
)}
{isCustomConflict && (
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{desensitize(
dialog.conflictCustom.replace("{name}", validation.skill_name),
)}
</span>
</div>
{isCustomConflict && (
<div
className={cn(
"mt-1 flex items-start gap-2 text-xs",
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
)}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{desensitize(customConflictMessage)}
</span>
</div>
)}
{isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
+12 -6
View File
@@ -591,20 +591,26 @@ export const enUS: Translations = {
chooseFile: "Choose file",
validating: "Validating archive…",
installing: "Installing…",
replace: "Replace existing",
replace: "Upload again and replace",
install: "Install",
cancel: "Cancel",
nameLabel: "Name",
descriptionLabel: "Description",
licenseLabel: "License",
toolsLabel: "Allowed tools",
warningsLabel: "Warnings",
conflictCustom:
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
warningsLabel: "Warnings",
conflictOwnedByMe:
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
conflictOwnedByMeWrongTarget:
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
conflictOwnedByOther:
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
+6 -3
View File
@@ -525,9 +525,12 @@ export interface Translations {
nameLabel: string;
descriptionLabel: string;
licenseLabel: string;
toolsLabel: string;
warningsLabel: string;
conflictCustom: string;
toolsLabel: string;
warningsLabel: string;
conflictOwnedByMe: string;
conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string;
conflictCustom: string;
conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string;
+11 -5
View File
@@ -566,18 +566,24 @@ export const zhCN: Translations = {
chooseFile: "选择文件",
validating: "正在校验文件…",
installing: "安装中…",
replace: "覆盖已有",
replace: "重复上传并覆盖",
install: "安装",
cancel: "取消",
nameLabel: "名称",
descriptionLabel: "描述",
licenseLabel: "许可证",
toolsLabel: "允许的工具",
warningsLabel: "警告",
conflictCustom:
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
warningsLabel: "警告",
conflictOwnedByMe:
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
conflictOwnedByMeWrongTarget:
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
conflictOwnedByOther:
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
+32 -17
View File
@@ -175,16 +175,24 @@ export interface PreviewSkillInstallResponse {
warnings: string[];
}
export interface ValidateSkillUploadResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
conflict: boolean;
conflict_kind: "public" | "custom" | null;
warnings: string[];
}
export interface ValidateSkillUploadResponse {
ok: boolean;
skill_name: string;
description: string;
license: string | null;
allowed_tools: string[];
conflict: boolean;
conflict_kind: "public" | "custom" | null;
can_install_as_is: boolean;
can_overwrite: boolean;
owned_by_me: boolean;
owned_by_other: boolean;
builtin_conflict: boolean;
owner_username?: string | null;
owner_user_id?: string | null;
message: string;
warnings: string[];
}
export async function installSkill(
request: InstallSkillRequest,
@@ -235,13 +243,20 @@ async function parseDetailedError(response: Response, fallback: string): Promise
return new Error(message);
}
export async function validateSkillUpload(file: File): Promise<ValidateSkillUploadResponse> {
const form = new FormData();
form.append("file", file, file.name);
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/validate`, {
method: "POST",
body: form,
});
export async function validateSkillUpload(
file: File,
targetCategory: "public" | "custom" = "custom",
): Promise<ValidateSkillUploadResponse> {
const form = new FormData();
form.append("file", file, file.name);
const params = new URLSearchParams({ target_category: targetCategory });
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/validate?${params.toString()}`,
{
method: "POST",
body: form,
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Validation failed");
}
+11 -5
View File
@@ -192,11 +192,17 @@ export function useSetSkillAlwaysOn() {
});
}
export function useValidateSkillUpload() {
return useMutation({
mutationFn: (file: File) => validateSkillUpload(file),
});
}
export function useValidateSkillUpload() {
return useMutation({
mutationFn: ({
file,
targetCategory,
}: {
file: File;
targetCategory?: "public" | "custom";
}) => validateSkillUpload(file, targetCategory),
});
}
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
+54 -17
View File
@@ -320,7 +320,7 @@ export async function stopActiveThreadBeforeClarificationReply(
await new Promise((resolve) => setTimeout(resolve, 120));
}
function getStreamErrorMessage(error: unknown): string {
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
}
@@ -340,8 +340,21 @@ function getStreamErrorMessage(error: unknown): string {
return nestedError;
}
}
return "Request failed.";
}
return "Request failed.";
}
/**
* React production error #185 is the minified form of "Maximum update depth
* exceeded". It is a client render-loop guard, not a backend/model failure,
* so it must never be surfaced as the answer's stream error.
*/
function isReactMaximumUpdateDepthError(message: string): boolean {
return (
/maximum update depth exceeded/i.test(message) ||
/minified react error\s*#185\b/i.test(message) ||
/react\.dev\/errors\/185\b/i.test(message)
);
}
const TERMINAL_RUN_STATUSES = new Set([
"success",
@@ -674,11 +687,17 @@ export function useThreadStream({
runMetadataStorageRef.current = getRunMetadataStorage();
}
const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock),
assistantId: assistantId ?? "lead_agent",
threadId: onStreamThreadId,
reconnectOnMount: runMetadataStorageRef.current
const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock),
assistantId: assistantId ?? "lead_agent",
threadId: onStreamThreadId,
// The SDK's current implementation falls back to synchronous external-
// store notifications when this option is omitted. A network chunk can
// contain dozens of token events, causing nested React renders and the
// intermittent production-only React #185 error. Coalesce notifications
// to one visual update per frame; stream data itself is not dropped.
throttle: 16,
reconnectOnMount: runMetadataStorageRef.current
? () => runMetadataStorageRef.current!
: false,
fetchStateHistory: { limit: 1 },
@@ -843,10 +862,21 @@ export function useThreadStream({
toast(e.message);
}
},
onError(error) {
markStreamActivity();
const message = getStreamErrorMessage(error);
// Replacing a stalled local SSE reader aborts the in-flight fetch. That
onError(error) {
markStreamActivity();
const message = getStreamErrorMessage(error);
// A synchronous SDK store notification used to let React's update-depth
// guard escape into the stream promise. Do not present that internal UI
// exception as a model failure. Reconcile/rejoin on the next task so the
// backend run can continue and the current answer is preserved.
if (isReactMaximumUpdateDepthError(message)) {
window.setTimeout(() => {
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("error");
}, 0);
return;
}
// Replacing a stalled local SSE reader aborts the in-flight fetch. That
// is expected and must not look like a user-facing "network" failure.
if (isAbortLikeError(error)) {
return;
@@ -1675,13 +1705,20 @@ export function useThreadStream({
thread.isLoading,
) ?? thread.values;
const mergedMessages =
const mergedMessages =
Array.isArray(mergedValues?.messages)
? (mergedValues.messages as Message[])
: thread.messages;
const mergedThread = {
...thread,
: thread.messages;
const visibleThreadError =
thread.error &&
isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error))
? undefined
: thread.error;
const mergedThread = {
...thread,
error: visibleThreadError,
values: mergedValues,
messages:
optimisticMessages.length > 0