fix: harden agent snapshot restore flow

This commit is contained in:
Tyke Chen
2026-07-10 20:21:44 +08:00
parent 687b6db96b
commit 179281e49c
47 changed files with 5701 additions and 677 deletions
+125 -41
View File
@@ -1,6 +1,41 @@
import { getServiceUrl } from '../api';
import RequestService from '../httpRequest';
const CALLBACK_RETRY_LIMIT = 10;
const CALLBACK_RETRY_DELAY_MS = 2000;
const CALLBACK_RETRY_WINDOW_MS = CALLBACK_RETRY_LIMIT * CALLBACK_RETRY_DELAY_MS;
function retryCallbackRequest(retry, retryCount, onTerminalFailure, error, retryStartedAt) {
if (!onTerminalFailure) {
RequestService.reAjaxFun(() => retry(retryCount + 1))
return
}
const startedAt = retryStartedAt || Date.now()
if (retryCount >= CALLBACK_RETRY_LIMIT || Date.now() - startedAt >= CALLBACK_RETRY_WINDOW_MS) {
RequestService.clearRequestTime()
onTerminalFailure(error)
return
}
setTimeout(() => retry(retryCount + 1, startedAt), CALLBACK_RETRY_DELAY_MS)
}
function attachTerminalFailure(request, onTerminalFailure) {
if (onTerminalFailure) {
request.fail((error) => {
RequestService.clearRequestTime()
onTerminalFailure(error)
})
}
return request
}
function terminateCallbackRequest(onTerminalFailure, error) {
RequestService.clearRequestTime()
if (onTerminalFailure) {
onTerminalFailure(error)
}
}
export default {
// 获取智能体列表
@@ -50,8 +85,9 @@ export default {
}).send();
},
// 获取智能体配置
getDeviceConfig(agentId, callback) {
RequestService.sendRequest()
getDeviceConfig(agentId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now()
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}`)
.method('GET')
.success((res) => {
@@ -60,10 +96,21 @@ export default {
})
.networkFail((err) => {
console.error('获取配置失败:', err);
RequestService.reAjaxFun(() => {
this.getDeviceConfig(agentId, callback);
});
}).send();
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getDeviceConfig(
agentId,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
err,
retryWindowStartedAt
)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 配置智能体
updateAgentConfig(agentId, configData, callback) {
@@ -82,8 +129,9 @@ export default {
}).send();
},
// 获取智能体配置快照列表
getAgentSnapshots(agentId, params, callback) {
RequestService.sendRequest()
getAgentSnapshots(agentId, params, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now()
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/snapshots`)
.method('GET')
.data(params)
@@ -91,56 +139,80 @@ export default {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getAgentSnapshots(agentId, params, callback);
});
}).send();
.networkFail((error) => {
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getAgentSnapshots(
agentId,
params,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
error,
retryWindowStartedAt
)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 获取智能体配置快照详情
getAgentSnapshot(agentId, snapshotId, callback) {
RequestService.sendRequest()
getAgentSnapshot(agentId, snapshotId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now()
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getAgentSnapshot(agentId, snapshotId, callback);
});
}).send();
.networkFail((error) => {
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getAgentSnapshot(
agentId,
snapshotId,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
error,
retryWindowStartedAt
)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 恢复智能体配置快照
restoreAgentSnapshot(agentId, snapshotId, callback) {
RequestService.sendRequest()
restoreAgentSnapshot(agentId, snapshotId, currentStateToken, callback, onTerminalFailure) {
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}/restore`)
.method('POST')
.data({ currentStateToken })
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.restoreAgentSnapshot(agentId, snapshotId, callback);
});
}).send();
.networkFail((error) => {
terminateCallbackRequest(onTerminalFailure, error)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 删除智能体配置快照
deleteAgentSnapshot(agentId, snapshotId, callback) {
RequestService.sendRequest()
deleteAgentSnapshot(agentId, snapshotId, callback, onTerminalFailure) {
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`)
.method('DELETE')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.deleteAgentSnapshot(agentId, snapshotId, callback);
});
}).send();
.networkFail((error) => {
terminateCallbackRequest(onTerminalFailure, error)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 新增方法:获取智能体模板
getAgentTemplate(callback) { // 移除templateName参数
@@ -463,19 +535,31 @@ export default {
}).send();
},
// 获取智能体标签
getAgentTags(agentId, callback) {
RequestService.sendRequest()
getAgentTags(agentId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now()
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/tags`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getAgentTags(agentId, callback);
});
}).send();
.networkFail((error) => {
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getAgentTags(
agentId,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
error,
retryWindowStartedAt
)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 保存智能体标签
saveAgentTags(agentId, tags, callback) {
@@ -0,0 +1,123 @@
/* eslint-disable test/no-import-node-test -- zero-dependency API regression gate */
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const agentApiSource = await readFile(new URL("./agent.js", import.meta.url), "utf8");
const snapshotDialogSource = await readFile(
new URL("../../components/AgentSnapshotDialog.vue", import.meta.url),
"utf8"
);
function sourceBetween(source, startMarker, endMarker) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.notEqual(start, -1, `missing source marker: ${startMarker}`);
assert.notEqual(end, -1, `missing source marker: ${endMarker}`);
return source.slice(start, end);
}
test("snapshot restore sends the preview token once without an automatic replay", () => {
const source = sourceBetween(
agentApiSource,
"restoreAgentSnapshot(agentId",
"// 删除智能体配置快照"
);
assert.match(source, /restoreAgentSnapshot\(agentId, snapshotId, currentStateToken, callback, onTerminalFailure\)/);
assert.match(source, /\.method\('POST'\)/);
assert.match(source, /\.data\(\{ currentStateToken \}\)/);
assert.match(source, /terminateCallbackRequest\(onTerminalFailure, error\)/);
assert.doesNotMatch(source, /retryCallbackRequest|RequestService\.reAjaxFun|this\.restoreAgentSnapshot/);
});
test("snapshot deletion terminates on network failure without an automatic replay", () => {
const source = sourceBetween(
agentApiSource,
"deleteAgentSnapshot(agentId",
"// 新增方法:获取智能体模板"
);
assert.match(source, /deleteAgentSnapshot\(agentId, snapshotId, callback, onTerminalFailure\)/);
assert.match(source, /\.method\('DELETE'\)/);
assert.match(source, /terminateCallbackRequest\(onTerminalFailure, error\)/);
assert.doesNotMatch(source, /retryCallbackRequest|RequestService\.reAjaxFun|this\.deleteAgentSnapshot/);
});
test("restore preview uses the state data and token from one snapshot-detail response", () => {
const source = sourceBetween(
snapshotDialogSource,
"restoreSnapshot(row)",
"decorateSnapshotRows(rows)"
);
assert.match(source, /this\.fetchSnapshotDetail\(row\.id\)\.then\(\(targetSnapshot\)/);
assert.match(source, /beforeSnapshotData: targetSnapshot\.currentSnapshotData/);
assert.match(source, /hasValidCurrentStateToken\(targetSnapshot\.currentStateToken\)/);
assert.doesNotMatch(source, /fetchCurrentAgentData|fetchCurrentAgentTags|Promise\.all/);
});
test("snapshot dialogs cannot close while a restore request is in flight", () => {
const guardedDialogs = snapshotDialogSource.match(/:before-close="guardRestoreInFlightClose"/g) || [];
const hiddenCloseButtons = snapshotDialogSource.match(/:show-close="!restoring"/g) || [];
assert.equal(guardedDialogs.length, 2);
assert.equal(hiddenCloseButtons.length, 2);
assert.match(
snapshotDialogSource,
/class="snapshot-footer-button snapshot-footer-cancel"[\s\S]*?:disabled="restoring"[\s\S]*?@click="closeRestorePreview"/
);
const closeMethods = sourceBetween(
snapshotDialogSource,
" close() {",
" open() {"
);
assert.match(closeMethods, /close\(\) \{\s+if \(this\.restoring\) \{\s+return;/);
assert.match(closeMethods, /guardRestoreInFlightClose\(done\) \{\s+if \(this\.restoring\) \{\s+return;[\s\S]*?done\(\);/);
assert.match(closeMethods, /closeRestorePreview\(\) \{\s+if \(this\.restoring\) \{\s+return;[\s\S]*?this\.restorePreviewVisible = false;/);
});
test("destructive restore requires a second explicit warning before the POST", () => {
const source = sourceBetween(
snapshotDialogSource,
" confirmRestoreSnapshot() {",
" deleteSnapshot(row) {"
);
assert.match(
source,
/if \(!this\.restoreWillClearChatHistory\) \{\s+this\.submitRestoreSnapshot\(snapshotId, currentStateToken\);\s+return;\s+\}/
);
assert.match(
source,
/this\.\$confirm\(\s+this\.\$t\("agentSnapshot\.restoreMemoryDestructiveWarning"\),[\s\S]*?type: "error"[\s\S]*?\)\.then\(\(\) => \{[\s\S]*?this\.submitRestoreSnapshot\(snapshotId, currentStateToken, requestSeq\);/
);
assert.match(source, /if \(this\.restoring \|\| !this\.restorePreviewRow\) \{\s+return;/);
});
test("a terminal restore failure invalidates the atomic preview instead of replaying it", () => {
const source = sourceBetween(
snapshotDialogSource,
" submitRestoreSnapshot(snapshotId",
" deleteSnapshot(row) {"
);
assert.match(source, /else \{\s+this\.invalidateRestorePreview\(\);\s+this\.\$message\.error\(this\.restoreFailedMessage\(data\)\);/);
assert.match(source, /\}, \(\) => \{[\s\S]*?this\.invalidateRestorePreview\(\);\s+this\.\$message\.error\(this\.\$t\("agentSnapshot\.restoreFailed"\)\);/);
assert.match(source, /invalidateRestorePreview\(\) \{[\s\S]*?this\.restorePreviewSnapshot = null;[\s\S]*?this\.restorePreviewRow = null;/);
});
test("the latest snapshot does not expose a restore action", () => {
const source = sourceBetween(
snapshotDialogSource,
" canRestoreSnapshot(row) {",
" canDeleteSnapshot(row) {"
);
assert.match(source, /!!row\?\.id && !row\.isLatestSnapshot/);
assert.match(snapshotDialogSource, /v-if="canRestoreSnapshot\(scope\.row\)"/);
assert.match(
snapshotDialogSource,
/restoreSnapshot\(row\) \{\s+if \(!this\.canRestoreSnapshot\(row\)\) \{\s+return;/
);
});
@@ -1,6 +1,26 @@
import { getServiceUrl } from '../api';
import RequestService from '../httpRequest';
const CALLBACK_RETRY_LIMIT = 10;
const CALLBACK_RETRY_DELAY_MS = 2000;
const CALLBACK_RETRY_WINDOW_MS = CALLBACK_RETRY_LIMIT * CALLBACK_RETRY_DELAY_MS;
function retryCallbackRequest(retry, retryCount, onTerminalFailure, error, retryStartedAt) {
if (!onTerminalFailure) {
RequestService.reAjaxFun(() => retry(retryCount + 1))
return
}
const startedAt = retryStartedAt || Date.now()
if (retryCount >= CALLBACK_RETRY_LIMIT || Date.now() - startedAt >= CALLBACK_RETRY_WINDOW_MS) {
RequestService.clearRequestTime()
if (onTerminalFailure) {
onTerminalFailure(error)
}
return
}
setTimeout(() => retry(retryCount + 1, startedAt), CALLBACK_RETRY_DELAY_MS)
}
export default {
// 获取替换词文件列表
@@ -26,8 +46,9 @@ export default {
},
// 获取所有替换词文件(不分页)
selectAll(callback) {
RequestService.sendRequest()
selectAll(callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now()
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/correct-word/file/select`)
.method('GET')
.success((res) => {
@@ -36,10 +57,26 @@ export default {
})
.networkFail((err) => {
console.error('获取所有替换词文件失败:', err)
RequestService.reAjaxFun(() => {
this.selectAll(callback)
})
}).send()
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.selectAll(
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
err,
retryWindowStartedAt
)
})
if (onTerminalFailure) {
request.fail((error) => {
RequestService.clearRequestTime()
onTerminalFailure(error)
})
}
request.send()
},
// 下载替换词文件
+124 -28
View File
@@ -1,6 +1,26 @@
import { getServiceUrl } from '../api';
import RequestService from '../httpRequest';
const CALLBACK_RETRY_LIMIT = 10;
const CALLBACK_RETRY_DELAY_MS = 2000;
const CALLBACK_RETRY_WINDOW_MS = CALLBACK_RETRY_LIMIT * CALLBACK_RETRY_DELAY_MS;
function retryCallbackRequest(retry, retryCount, onTerminalFailure, error, retryStartedAt) {
if (!onTerminalFailure) {
RequestService.reAjaxFun(() => retry(retryCount + 1));
return;
}
const startedAt = retryStartedAt || Date.now();
if (retryCount >= CALLBACK_RETRY_LIMIT || Date.now() - startedAt >= CALLBACK_RETRY_WINDOW_MS) {
RequestService.clearRequestTime();
if (onTerminalFailure) {
onTerminalFailure(error);
}
return;
}
setTimeout(() => retry(retryCount + 1, startedAt), CALLBACK_RETRY_DELAY_MS);
}
export default {
// 获取模型配置列表
getModelList(params, callback) {
@@ -92,8 +112,9 @@ export default {
}).send()
},
// 获取模型名称列表
getModelNames(modelType, modelName, callback) {
RequestService.sendRequest()
getModelNames(modelType, modelName, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now();
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/models/names`)
.method('GET')
.data({ modelType, modelName })
@@ -101,15 +122,34 @@ export default {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getModelNames(modelType, modelName, callback);
});
}).send();
.networkFail((error) => {
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getModelNames(
modelType,
modelName,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
error,
retryWindowStartedAt
);
});
if (onTerminalFailure) {
request.fail((error) => {
RequestService.clearRequestTime();
onTerminalFailure(error);
});
}
request.send();
},
// 获取LLM模型名称列表
getLlmModelCodeList(modelName, callback) {
RequestService.sendRequest()
getLlmModelCodeList(modelName, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now();
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/models/llm/names`)
.method('GET')
.data({ modelName })
@@ -117,29 +157,65 @@ export default {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getLlmModelCodeList(modelName, callback);
});
}).send();
.networkFail((error) => {
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getLlmModelCodeList(
modelName,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
error,
retryWindowStartedAt
);
});
if (onTerminalFailure) {
request.fail((error) => {
RequestService.clearRequestTime();
onTerminalFailure(error);
});
}
request.send();
},
// 获取模型音色列表
getModelVoices(modelId, voiceName, callback) {
getModelVoices(modelId, voiceName, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now();
const queryParams = new URLSearchParams({
voiceName: voiceName || ''
}).toString();
RequestService.sendRequest()
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/models/${modelId}/voices?${queryParams}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getModelVoices(modelId, voiceName, callback);
});
}).send();
.networkFail((error) => {
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getModelVoices(
modelId,
voiceName,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
error,
retryWindowStartedAt
);
});
if (onTerminalFailure) {
request.fail((error) => {
RequestService.clearRequestTime();
onTerminalFailure(error);
});
}
request.send();
},
// 获取单个模型配置
getModelConfig(id, callback) {
@@ -323,8 +399,9 @@ export default {
}).send()
},
// 获取插件列表
getPluginFunctionList(params, callback) {
RequestService.sendRequest()
getPluginFunctionList(params, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
const retryWindowStartedAt = retryStartedAt || Date.now();
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/models/provider/plugin/names`)
.method('GET')
.success((res) => {
@@ -332,11 +409,30 @@ export default {
callback(res)
})
.networkFail((err) => {
this.$message.error(err.msg || '获取插件列表失败')
RequestService.reAjaxFun(() => {
this.getPluginFunctionList(params, callback)
})
}).send()
if (!onTerminalFailure && this.$message) {
this.$message.error(err.msg || '获取插件列表失败');
}
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getPluginFunctionList(
params,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
err,
retryWindowStartedAt
);
});
if (onTerminalFailure) {
request.fail((error) => {
RequestService.clearRequestTime();
onTerminalFailure(error);
});
}
request.send()
},
// 获取RAG模型列表