feat(web): 工作台 - 优化 Agent 工作区与技能库
This commit is contained in:
@@ -80,7 +80,9 @@ function unwrapTransportError(error: unknown): never {
|
||||
code,
|
||||
reason: error.response?.data?.reason,
|
||||
details: error.response?.data?.details,
|
||||
retryable: isRetryableStatus(status) || isRetryableStatus(code),
|
||||
// 没有 response 的 AxiosError 代表请求没有拿到 HTTP 响应(断网、连接重置、DNS、超时等)。
|
||||
// 这类错误不能靠 status 判断,但对保存来说通常是暂时性的,应交给上层有限退避重试。
|
||||
retryable: status === undefined || isRetryableStatus(status) || isRetryableStatus(code),
|
||||
retryAfterMs: retryAfterMilliseconds(error.response?.headers),
|
||||
cause: error,
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export type Skill = {
|
||||
contentHash: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
sourceType: "builtin" | "markdown" | "zip" | "github" | string;
|
||||
sourceType: "builtin" | "markdown" | "zip" | string;
|
||||
sourceUrl: string;
|
||||
sourceRef: string;
|
||||
sourceSubdir: string;
|
||||
@@ -55,12 +55,32 @@ export type Skill = {
|
||||
extraInfo: string;
|
||||
isAdded: boolean;
|
||||
isOwner: boolean;
|
||||
libraryCategoryId?: string;
|
||||
};
|
||||
|
||||
/** /skills/added 只返回运行时目录需要的引用字段,不携带编辑器和同步详情。 */
|
||||
export type AddedSkillReference = Pick<Skill, "skillId" | "skillName" | "description" | "versionId" | "version" | "tag" | "isLike" | "isAdded" | "isOwner">;
|
||||
export type AddedSkillReference = Pick<Skill, "skillId" | "skillName" | "description" | "versionId" | "version" | "tag" | "isLike" | "isAdded" | "isOwner" | "libraryCategoryId">;
|
||||
|
||||
export type SkillCategory = { value: string; label: string };
|
||||
export type SkillCategory = { value: string; label: string; count?: number };
|
||||
|
||||
|
||||
export type SkillLibraryCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: "personal" | "platform";
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type SkillLibraryCategoryList = {
|
||||
categories: SkillLibraryCategory[];
|
||||
totalCount: number;
|
||||
uncategorizedCount: number;
|
||||
};
|
||||
|
||||
export type SkillLibraryCategoryMutationInput = {
|
||||
name: string;
|
||||
scope?: "personal" | "platform";
|
||||
};
|
||||
|
||||
/**
|
||||
* 场景预设:平台只读目录(GET /skills/presets,随二进制内置)。
|
||||
@@ -95,6 +115,8 @@ export type ListSkillsInput = {
|
||||
sort?: SkillSort;
|
||||
search?: string;
|
||||
tag?: string;
|
||||
libraryCategoryId?: string;
|
||||
libraryUncategorized?: boolean;
|
||||
};
|
||||
|
||||
export type SkillMutationInput = {
|
||||
@@ -158,6 +180,23 @@ export function listSkills(input: ListSkillsInput = {}) {
|
||||
return http.get<SkillList>(`/skills?${params.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
export function listSkillLibraryCategories(scope: "mine" | "created" = "mine") {
|
||||
return http.get<SkillLibraryCategoryList>("/skills/library-categories", { params: { scope } });
|
||||
}
|
||||
|
||||
export function createSkillLibraryCategory(input: SkillLibraryCategoryMutationInput) {
|
||||
return http.post<{ category: SkillLibraryCategory }>("/skills/library-categories", input).finally(invalidateAddedSkillsCache);
|
||||
}
|
||||
|
||||
export function deleteSkillLibraryCategory(id: string) {
|
||||
return http.delete<{ deleted: boolean }>(`/skills/library-categories/${encodeURIComponent(id)}`).finally(invalidateAddedSkillsCache);
|
||||
}
|
||||
|
||||
export function setSkillLibraryCategory(skillId: string, categoryId: string) {
|
||||
return http.patch<{ skill: Skill }>(`/skills/${encodeURIComponent(skillId)}/library-category`, { categoryId }).finally(invalidateAddedSkillsCache);
|
||||
}
|
||||
|
||||
export function getSkill(id: string) {
|
||||
return http.get<{ skill: Skill }>(`/skills/${encodeURIComponent(id)}`);
|
||||
}
|
||||
@@ -237,6 +276,7 @@ function normalizeAddedSkillReference(skill: AddedSkillReference): Skill {
|
||||
extraInfo: "",
|
||||
isAdded: skill.isAdded,
|
||||
isOwner: skill.isOwner,
|
||||
libraryCategoryId: skill.libraryCategoryId || "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ let remoteUserDataPhase: RemoteUserDataPhase = "inactive";
|
||||
let syncTimer: number | null = null;
|
||||
let syncPromise: Promise<void> | null = null;
|
||||
let syncQueued = false;
|
||||
let syncRetryAttempt = 0;
|
||||
let remoteOperationTail: Promise<void> = Promise.resolve();
|
||||
let subscriptionsInstalled = false;
|
||||
let acknowledgedAssets = new Map<string, Asset>();
|
||||
@@ -35,6 +36,7 @@ let sessionEpoch = 0;
|
||||
const verifiedProjects = new Set<string>();
|
||||
const verifiedAssets = new Set<string>();
|
||||
const remoteProjectLoadPromises = new Map<string, Promise<CanvasProject | undefined>>();
|
||||
const REMOTE_SYNC_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
|
||||
|
||||
export async function initializeRemoteUserDataSession(userId: string) {
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
@@ -373,6 +375,7 @@ export function resetRemoteUserDataSync() {
|
||||
window.clearTimeout(syncTimer);
|
||||
syncTimer = null;
|
||||
}
|
||||
syncRetryAttempt = 0;
|
||||
syncQueued = false;
|
||||
useSyncProgressStore.getState().clearAll();
|
||||
}
|
||||
@@ -409,6 +412,66 @@ export function scheduleRemoteUserDataSync() {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function isRetryableRemoteSyncError(error: unknown) {
|
||||
if (error instanceof ApiError) return error.retryable;
|
||||
if (error instanceof DOMException && error.name === "AbortError") return false;
|
||||
if (typeof error === "object" && error !== null && "permanent" in error) {
|
||||
return (error as { permanent?: unknown }).permanent !== true;
|
||||
}
|
||||
// Plain errors from validation/local storage should remain visible instead of
|
||||
// being retried indefinitely. Transport failures are normalized as ApiError.
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateRetryProgress(delayMs: number) {
|
||||
const seconds = Math.max(1, Math.ceil(delayMs / 1_000));
|
||||
const progress = useSyncProgressStore.getState();
|
||||
for (const item of Object.values(progress.syncingProjects)) {
|
||||
if (item.phase !== "error") continue;
|
||||
progress.setProjectProgress(item.projectId, {
|
||||
message: `${item.message || "云端同步失败"};${seconds} 秒后自动重试`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopRetryProgress() {
|
||||
const progress = useSyncProgressStore.getState();
|
||||
for (const item of Object.values(progress.syncingProjects)) {
|
||||
if (item.phase !== "error" || !item.message?.includes("自动重试")) continue;
|
||||
progress.setProjectProgress(item.projectId, { message: "云端同步失败,请点击立即重试" });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRemoteUserDataRetry(error: unknown) {
|
||||
if (!isRetryableRemoteSyncError(error) || !activeRemoteUserId || remoteUserDataPhase !== "ready") return false;
|
||||
if (syncRetryAttempt >= REMOTE_SYNC_RETRY_DELAYS_MS.length) {
|
||||
stopRetryProgress();
|
||||
return false;
|
||||
}
|
||||
const delayMs = Math.max(
|
||||
REMOTE_SYNC_RETRY_DELAYS_MS[syncRetryAttempt],
|
||||
error instanceof ApiError ? error.retryAfterMs || 0 : 0,
|
||||
);
|
||||
syncRetryAttempt += 1;
|
||||
if (syncTimer) window.clearTimeout(syncTimer);
|
||||
updateRetryProgress(delayMs);
|
||||
syncTimer = window.setTimeout(() => {
|
||||
syncTimer = null;
|
||||
void saveRemoteUserDataNow().catch((retryError) => console.warn("云端自动重试失败", retryError));
|
||||
}, delayMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 用户主动点击重试时重新开始退避窗口,不影响本地已保存的内容。 */
|
||||
export function retryRemoteUserDataSync(projectId?: string) {
|
||||
syncRetryAttempt = 0;
|
||||
if (syncTimer) {
|
||||
window.clearTimeout(syncTimer);
|
||||
syncTimer = null;
|
||||
}
|
||||
return saveRemoteUserDataNow(projectId);
|
||||
}
|
||||
|
||||
export function formatLocalSavedRemotePending(localAction: string, error: unknown): string {
|
||||
const detail = error instanceof Error && error.message.trim() ? error.message.trim() : "未知错误";
|
||||
if (error instanceof ApiError && error.reason === "canvas_history_resources_missing") {
|
||||
@@ -616,13 +679,23 @@ export async function saveRemoteUserDataNow(input?: string | readonly string[] |
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
|
||||
await drainRemoteUserDataChanges(options);
|
||||
});
|
||||
let syncFailed = false;
|
||||
let syncError: unknown;
|
||||
try {
|
||||
await syncPromise;
|
||||
assertNoConflict();
|
||||
syncRetryAttempt = 0;
|
||||
} catch (error) {
|
||||
syncFailed = true;
|
||||
syncError = error;
|
||||
} finally {
|
||||
syncPromise = null;
|
||||
if (syncQueued) scheduleRemoteUserDataSync();
|
||||
}
|
||||
if (syncFailed) {
|
||||
scheduleRemoteUserDataRetry(syncError);
|
||||
throw syncError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user