mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-24 08:03:53 +08:00
fix: harden agent snapshot restore flow
This commit is contained in:
@@ -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()
|
||||
},
|
||||
|
||||
// 下载替换词文件
|
||||
|
||||
@@ -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模型列表
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
:visible="visible"
|
||||
width="760px"
|
||||
class="agent-snapshot-dialog"
|
||||
:before-close="guardRestoreInFlightClose"
|
||||
:close-on-click-modal="!restoring"
|
||||
:close-on-press-escape="!restoring"
|
||||
:show-close="!restoring"
|
||||
@open="open"
|
||||
@close="close"
|
||||
>
|
||||
@@ -302,6 +306,10 @@
|
||||
:visible.sync="restorePreviewVisible"
|
||||
width="860px"
|
||||
class="snapshot-detail-dialog"
|
||||
:before-close="guardRestoreInFlightClose"
|
||||
:close-on-click-modal="!restoring"
|
||||
:close-on-press-escape="!restoring"
|
||||
:show-close="!restoring"
|
||||
>
|
||||
<template slot="title">
|
||||
<div class="snapshot-dialog-title">
|
||||
@@ -431,25 +439,34 @@
|
||||
:title="$t('agentSnapshot.restoreConfirm', { version: restoreTargetVersion })"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="restoreWillClearChatHistory"
|
||||
v-if="restorePreviewSnapshot"
|
||||
class="restore-risk-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="$t('agentSnapshot.restoreMemoryWarning')"
|
||||
:title="$t('agentSnapshot.unsavedChangesWarning')"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="restoreWillClearChatHistory"
|
||||
class="restore-risk-alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="$t('agentSnapshot.restoreMemoryDestructiveWarning')"
|
||||
/>
|
||||
</div>
|
||||
<span slot="footer" class="snapshot-dialog-footer">
|
||||
<el-button
|
||||
class="snapshot-footer-button snapshot-footer-cancel"
|
||||
@click="restorePreviewVisible = false"
|
||||
:disabled="restoring"
|
||||
@click="closeRestorePreview"
|
||||
>
|
||||
{{ $t('button.cancel') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
class="snapshot-footer-button snapshot-footer-confirm"
|
||||
:loading="restoring"
|
||||
:disabled="restorePreviewLoading || !restorePreviewSnapshot || restorePreviewDiffs.length === 0"
|
||||
:disabled="restoring || restorePreviewLoading || !restorePreviewSnapshot || restorePreviewDiffs.length === 0"
|
||||
@click="confirmRestoreSnapshot"
|
||||
>
|
||||
<span class="confirm-inner">
|
||||
@@ -466,6 +483,12 @@
|
||||
import Api from "@/apis/api";
|
||||
import correctWord from "@/apis/module/correctWord";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import {
|
||||
hasValidCurrentStateToken,
|
||||
normalizeSnapshotOrderedValue,
|
||||
redactSnapshotDisplayValue,
|
||||
SNAPSHOT_SECRET_REDACTED
|
||||
} from "./agentSnapshotDisplayUtils.mjs";
|
||||
|
||||
const FALLBACK_PLUGIN_NAME_KEYS = {
|
||||
SYSTEM_PLUGIN_WEATHER: "agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER",
|
||||
@@ -533,8 +556,6 @@ const CHAT_HISTORY_CONF_LABEL_KEYS = {
|
||||
1: "agentSnapshot.chatHistoryConf.text",
|
||||
2: "agentSnapshot.chatHistoryConf.textVoice"
|
||||
};
|
||||
const SNAPSHOT_SECRET_REDACTED = "__SNAPSHOT_SECRET_REDACTED__";
|
||||
|
||||
export default {
|
||||
name: "AgentSnapshotDialog",
|
||||
props: {
|
||||
@@ -730,10 +751,25 @@ export default {
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (this.restoring) {
|
||||
return;
|
||||
}
|
||||
this.cancelPendingSnapshotRequests();
|
||||
this.historyAnchorVersionNo = null;
|
||||
this.$emit("update:visible", false);
|
||||
},
|
||||
guardRestoreInFlightClose(done) {
|
||||
if (this.restoring) {
|
||||
return;
|
||||
}
|
||||
done();
|
||||
},
|
||||
closeRestorePreview() {
|
||||
if (this.restoring) {
|
||||
return;
|
||||
}
|
||||
this.restorePreviewVisible = false;
|
||||
},
|
||||
open() {
|
||||
this.historyAnchorVersionNo = null;
|
||||
this.page = 1;
|
||||
@@ -761,7 +797,7 @@ export default {
|
||||
return !!row?.id;
|
||||
},
|
||||
canRestoreSnapshot(row) {
|
||||
return !!row && !row.isLatestSnapshot;
|
||||
return !!row?.id && !row.isLatestSnapshot;
|
||||
},
|
||||
canDeleteSnapshot(row) {
|
||||
return !!row && !row.isLatestSnapshot;
|
||||
@@ -806,6 +842,13 @@ export default {
|
||||
} else {
|
||||
this.$message.error(data.msg || this.$t("agentSnapshot.fetchFailed"));
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (requestSeq !== this.snapshotFetchSeq) {
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
this.$message.error(this.$t("agentSnapshot.fetchFailed"));
|
||||
}
|
||||
);
|
||||
},
|
||||
@@ -855,16 +898,20 @@ export default {
|
||||
this.ensurePluginMetadata();
|
||||
this.ensureModelMetadata();
|
||||
|
||||
Promise.all([
|
||||
this.fetchSnapshotDetail(row.id),
|
||||
this.fetchCurrentAgentData()
|
||||
]).then(([targetSnapshot, currentData]) => {
|
||||
this.fetchSnapshotDetail(row.id).then((targetSnapshot) => {
|
||||
if (requestSeq !== this.restorePreviewFetchSeq) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (
|
||||
!this.isPlainObject(targetSnapshot.currentSnapshotData)
|
||||
|| !hasValidCurrentStateToken(targetSnapshot.currentStateToken)
|
||||
) {
|
||||
throw new Error("Snapshot detail is missing its atomic current-state preview");
|
||||
}
|
||||
const previewSnapshot = {
|
||||
...targetSnapshot,
|
||||
beforeSnapshotData: currentData,
|
||||
currentStateToken: targetSnapshot.currentStateToken,
|
||||
beforeSnapshotData: targetSnapshot.currentSnapshotData,
|
||||
afterSnapshotData: targetSnapshot.snapshotData || {},
|
||||
beforeVersionNo: this.resolveCurrentVersionNo(),
|
||||
afterVersionNo: targetSnapshot.versionNo,
|
||||
@@ -895,12 +942,56 @@ export default {
|
||||
}));
|
||||
},
|
||||
confirmRestoreSnapshot() {
|
||||
if (!this.restorePreviewRow) {
|
||||
if (this.restoring || !this.restorePreviewRow) {
|
||||
return;
|
||||
}
|
||||
const snapshotId = this.restorePreviewRow.id;
|
||||
const currentStateToken = this.restorePreviewSnapshot?.currentStateToken;
|
||||
if (!hasValidCurrentStateToken(currentStateToken)) {
|
||||
this.invalidateRestorePreview();
|
||||
this.$message.error(this.$t("agentSnapshot.restoreFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.restoreWillClearChatHistory) {
|
||||
this.submitRestoreSnapshot(snapshotId, currentStateToken);
|
||||
return;
|
||||
}
|
||||
|
||||
this.restoring = true;
|
||||
const requestSeq = ++this.restoreActionSeq;
|
||||
this.$confirm(
|
||||
this.$t("agentSnapshot.restoreMemoryDestructiveWarning"),
|
||||
this.$t("common.warning"),
|
||||
{
|
||||
confirmButtonText: this.$t("common.confirm"),
|
||||
cancelButtonText: this.$t("common.cancel"),
|
||||
type: "error"
|
||||
}
|
||||
).then(() => {
|
||||
if (requestSeq !== this.restoreActionSeq) {
|
||||
return;
|
||||
}
|
||||
this.submitRestoreSnapshot(snapshotId, currentStateToken, requestSeq);
|
||||
}).catch(() => {
|
||||
if (requestSeq === this.restoreActionSeq) {
|
||||
this.restoring = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
submitRestoreSnapshot(snapshotId, currentStateToken, confirmedRequestSeq = null) {
|
||||
if (!hasValidCurrentStateToken(currentStateToken)) {
|
||||
this.restoring = false;
|
||||
this.invalidateRestorePreview();
|
||||
this.$message.error(this.$t("agentSnapshot.restoreFailed"));
|
||||
return;
|
||||
}
|
||||
const requestSeq = confirmedRequestSeq ?? ++this.restoreActionSeq;
|
||||
if (requestSeq !== this.restoreActionSeq) {
|
||||
return;
|
||||
}
|
||||
this.restoring = true;
|
||||
const requestSeq = ++this.restoreActionSeq;
|
||||
Api.agent.restoreAgentSnapshot(this.agentId, this.restorePreviewRow.id, ({ data }) => {
|
||||
Api.agent.restoreAgentSnapshot(this.agentId, snapshotId, currentStateToken, ({ data }) => {
|
||||
if (requestSeq !== this.restoreActionSeq) {
|
||||
return;
|
||||
}
|
||||
@@ -908,15 +999,32 @@ export default {
|
||||
if (data.code === 0) {
|
||||
this.$message.success(this.$t("agentSnapshot.restoreSuccess"));
|
||||
this.restorePreviewVisible = false;
|
||||
this.restorePreviewSnapshot = null;
|
||||
this.restorePreviewRow = null;
|
||||
this.detailVisible = false;
|
||||
this.$emit("restored");
|
||||
this.historyAnchorVersionNo = null;
|
||||
this.fetchSnapshots();
|
||||
} else {
|
||||
this.invalidateRestorePreview();
|
||||
this.$message.error(this.restoreFailedMessage(data));
|
||||
}
|
||||
}, () => {
|
||||
if (requestSeq !== this.restoreActionSeq) {
|
||||
return;
|
||||
}
|
||||
this.restoring = false;
|
||||
this.invalidateRestorePreview();
|
||||
this.$message.error(this.$t("agentSnapshot.restoreFailed"));
|
||||
});
|
||||
},
|
||||
invalidateRestorePreview() {
|
||||
this.restorePreviewFetchSeq += 1;
|
||||
this.restorePreviewLoading = false;
|
||||
this.restorePreviewVisible = false;
|
||||
this.restorePreviewSnapshot = null;
|
||||
this.restorePreviewRow = null;
|
||||
},
|
||||
deleteSnapshot(row) {
|
||||
if (!this.canDeleteSnapshot(row)) {
|
||||
return;
|
||||
@@ -946,6 +1054,12 @@ export default {
|
||||
} else {
|
||||
this.$message.error(data.msg || this.$t("agentSnapshot.deleteFailed"));
|
||||
}
|
||||
}, () => {
|
||||
if (requestSeq !== this.deleteActionSeq) {
|
||||
return;
|
||||
}
|
||||
this.deletingSnapshotId = null;
|
||||
this.$message.error(this.$t("agentSnapshot.deleteFailed"));
|
||||
});
|
||||
}).catch(() => {});
|
||||
},
|
||||
@@ -992,7 +1106,7 @@ export default {
|
||||
} else {
|
||||
reject(data);
|
||||
}
|
||||
});
|
||||
}, reject);
|
||||
});
|
||||
},
|
||||
fetchSnapshotRows(params) {
|
||||
@@ -1003,7 +1117,7 @@ export default {
|
||||
} else {
|
||||
reject(data);
|
||||
}
|
||||
});
|
||||
}, reject);
|
||||
});
|
||||
},
|
||||
snapshotQueryParams(params = {}) {
|
||||
@@ -1192,6 +1306,9 @@ export default {
|
||||
this.pluginMetadataLoaded = true;
|
||||
}
|
||||
resolve();
|
||||
}, () => {
|
||||
this.pluginMetadataLoaded = true;
|
||||
resolve();
|
||||
});
|
||||
}).finally(() => {
|
||||
this.pluginMetadataLoading = null;
|
||||
@@ -1221,9 +1338,9 @@ export default {
|
||||
};
|
||||
|
||||
if (type === "LLM") {
|
||||
Api.model.getLlmModelCodeList("", callback);
|
||||
Api.model.getLlmModelCodeList("", callback, () => resolve([]));
|
||||
} else {
|
||||
Api.model.getModelNames(type, "", callback);
|
||||
Api.model.getModelNames(type, "", callback, () => resolve([]));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1281,6 +1398,10 @@ export default {
|
||||
this.correctWordMetadataLoaded = true;
|
||||
}
|
||||
resolve();
|
||||
}, () => {
|
||||
// 权限不足或元数据服务不可用时保留原始 ID,不扩大文件列表的访问边界。
|
||||
this.correctWordMetadataLoaded = true;
|
||||
resolve();
|
||||
});
|
||||
}).finally(() => {
|
||||
this.correctWordMetadataLoading = null;
|
||||
@@ -1324,6 +1445,12 @@ export default {
|
||||
};
|
||||
}
|
||||
resolve();
|
||||
}, () => {
|
||||
this.loadedVoiceModelIds = {
|
||||
...this.loadedVoiceModelIds,
|
||||
[modelId]: true
|
||||
};
|
||||
resolve();
|
||||
});
|
||||
}).finally(() => {
|
||||
const { [modelId]: dropped, ...rest } = this.voiceMetadataLoading;
|
||||
@@ -1358,7 +1485,7 @@ export default {
|
||||
} else {
|
||||
reject(data);
|
||||
}
|
||||
});
|
||||
}, reject);
|
||||
});
|
||||
const tagsPromise = this.fetchCurrentAgentTags();
|
||||
|
||||
@@ -1378,7 +1505,7 @@ export default {
|
||||
} else {
|
||||
reject(data);
|
||||
}
|
||||
});
|
||||
}, reject);
|
||||
});
|
||||
},
|
||||
normalizeAgentData(data) {
|
||||
@@ -1576,7 +1703,7 @@ export default {
|
||||
return paramKeys.map((key) => ({
|
||||
key,
|
||||
label: this.functionParamLabel(pluginId, key),
|
||||
value: this.formatFunctionParamValue(normalizedParams[key]),
|
||||
value: this.formatFunctionParamValue(normalizedParams[key], key),
|
||||
changed: changedParamKeys.includes(key)
|
||||
}));
|
||||
},
|
||||
@@ -1604,11 +1731,11 @@ export default {
|
||||
return item.id === pluginId || item.providerCode === pluginId || item.name === pluginId;
|
||||
}) || null;
|
||||
},
|
||||
formatFunctionParamValue(value) {
|
||||
formatFunctionParamValue(value, key = "") {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return this.$t("agentSnapshot.emptyValue");
|
||||
}
|
||||
const displayValue = this.localizedSnapshotDisplayValue(value);
|
||||
const displayValue = this.localizedSnapshotDisplayValue(value, key);
|
||||
if (typeof displayValue === "object") {
|
||||
return JSON.stringify(displayValue);
|
||||
}
|
||||
@@ -1796,7 +1923,7 @@ export default {
|
||||
return this.normalizeFunctionMap(value);
|
||||
}
|
||||
if (field === "contextProviders") {
|
||||
return this.normalizeSortedJsonList(value);
|
||||
return normalizeSnapshotOrderedValue(Array.isArray(value) ? value : []);
|
||||
}
|
||||
if (["ttsVolume", "ttsRate", "ttsPitch"].includes(field)) {
|
||||
return this.normalizeDefaultTtsNumber(value);
|
||||
@@ -1813,17 +1940,9 @@ export default {
|
||||
}
|
||||
return this.normalizeValue(value);
|
||||
},
|
||||
normalizeSortedJsonList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((item) => this.normalizeValue(item))
|
||||
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
||||
},
|
||||
normalizeDefaultTtsNumber(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === "") {
|
||||
return 0;
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Math.trunc(value);
|
||||
@@ -1854,16 +1973,7 @@ export default {
|
||||
return left === right;
|
||||
},
|
||||
normalizeValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.normalizeValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.keys(value).sort().reduce((result, key) => {
|
||||
result[key] = this.normalizeValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value === undefined ? null : value;
|
||||
return normalizeSnapshotOrderedValue(value);
|
||||
},
|
||||
isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -1882,7 +1992,7 @@ export default {
|
||||
if (field === "correctWordFileIds") {
|
||||
return this.correctWordDisplayNames(value);
|
||||
}
|
||||
return this.formatValue(value);
|
||||
return this.formatValue(value, field);
|
||||
},
|
||||
modelDisplayName(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
@@ -1905,14 +2015,14 @@ export default {
|
||||
.map((id) => this.correctWordNameMap[id] || id)
|
||||
.join(", ");
|
||||
},
|
||||
formatValue(value) {
|
||||
formatValue(value, parentKey = "") {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return this.$t("agentSnapshot.emptyValue");
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return this.$t("agentSnapshot.emptyValue");
|
||||
}
|
||||
const displayValue = this.localizedSnapshotDisplayValue(value);
|
||||
const displayValue = this.localizedSnapshotDisplayValue(value, parentKey);
|
||||
if (Array.isArray(displayValue) && displayValue.every((item) => this.isPrimitiveValue(item))) {
|
||||
return displayValue.join(", ");
|
||||
}
|
||||
@@ -1921,20 +2031,12 @@ export default {
|
||||
}
|
||||
return String(displayValue);
|
||||
},
|
||||
localizedSnapshotDisplayValue(value) {
|
||||
if (value === SNAPSHOT_SECRET_REDACTED) {
|
||||
return this.$t("agentSnapshot.secretRedacted");
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.localizedSnapshotDisplayValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.keys(value).reduce((result, key) => {
|
||||
result[key] = this.localizedSnapshotDisplayValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
localizedSnapshotDisplayValue(value, parentKey = "") {
|
||||
return redactSnapshotDisplayValue(
|
||||
value,
|
||||
this.$t("agentSnapshot.secretRedacted"),
|
||||
parentKey
|
||||
);
|
||||
},
|
||||
isPrimitiveValue(value) {
|
||||
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
export const SNAPSHOT_SECRET_REDACTED = "__SNAPSHOT_SECRET_REDACTED__";
|
||||
|
||||
export function hasValidCurrentStateToken(value) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function redactSnapshotDisplayValue(value, redactedLabel, parentKey = "") {
|
||||
if (value === SNAPSHOT_SECRET_REDACTED || isSensitiveSnapshotKey(parentKey)) {
|
||||
return redactedLabel;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactSnapshotDisplayValue(item, redactedLabel, parentKey));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const sensitiveEntry = Object.keys(value).some((key) => {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
return (normalizedKey === "key" || normalizedKey === "name")
|
||||
&& typeof value[key] === "string"
|
||||
&& isSensitiveSnapshotKey(value[key]);
|
||||
});
|
||||
return Object.keys(value).reduce((result, key) => {
|
||||
const semanticKey = resolveUrlSemanticKey(parentKey, key);
|
||||
if (sensitiveEntry && key.toLowerCase() === "value") {
|
||||
result[key] = redactedLabel;
|
||||
} else {
|
||||
result[key] = redactSnapshotDisplayValue(value[key], redactedLabel, semanticKey);
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
if (typeof value === "string" && isSnapshotUrlValue(parentKey, value)) {
|
||||
return redactSnapshotUrl(value, redactedLabel, parentKey);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeSnapshotOrderedValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeSnapshotOrderedValue(item));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return Object.keys(value).sort().reduce((result, key) => {
|
||||
result[key] = normalizeSnapshotOrderedValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value === undefined ? null : value;
|
||||
}
|
||||
|
||||
export function isSensitiveSnapshotKey(key) {
|
||||
const normalized = String(key || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
return normalized === "authorization"
|
||||
|| normalized.includes("authorization")
|
||||
|| normalized.includes("authentication")
|
||||
|| normalized === "auth"
|
||||
|| normalized.endsWith("auth")
|
||||
|| normalized === "cookie"
|
||||
|| normalized === "cookie2"
|
||||
|| normalized === "setcookie"
|
||||
|| normalized === "setcookie2"
|
||||
|| normalized.endsWith("cookie")
|
||||
|| normalized === "session"
|
||||
|| normalized.endsWith("session")
|
||||
|| normalized.includes("sessionid")
|
||||
|| normalized.includes("sessionkey")
|
||||
|| normalized.includes("sessiontoken")
|
||||
|| normalized.includes("sessioncookie")
|
||||
|| normalized.endsWith("sessid")
|
||||
|| normalized === "token"
|
||||
|| normalized.endsWith("token")
|
||||
|| normalized.includes("apikey")
|
||||
|| normalized.includes("appkey")
|
||||
|| normalized.includes("accesskey")
|
||||
|| normalized.includes("subscriptionkey")
|
||||
|| normalized.includes("privatekey")
|
||||
|| normalized.includes("password")
|
||||
|| normalized.includes("passwd")
|
||||
|| normalized.includes("secret")
|
||||
|| normalized.includes("credential");
|
||||
}
|
||||
|
||||
function isSnapshotUrlValue(parentKey, value) {
|
||||
const normalizedKey = String(parentKey || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
return normalizedKey.includes("url")
|
||||
|| normalizedKey.endsWith("uri")
|
||||
|| normalizedKey.includes("endpoint")
|
||||
|| normalizedKey.includes("webhook")
|
||||
|| /^(?:[a-z][a-z0-9+.-]*:)*\/\//i.test(value);
|
||||
}
|
||||
|
||||
function resolveUrlSemanticKey(parentKey, childKey) {
|
||||
if (isWebhookSemanticKey(childKey)) {
|
||||
return childKey;
|
||||
}
|
||||
if (isWebhookSemanticKey(parentKey)) {
|
||||
return childKey ? `${parentKey}.${childKey}` : parentKey;
|
||||
}
|
||||
return childKey;
|
||||
}
|
||||
|
||||
function isWebhookSemanticKey(value) {
|
||||
const normalized = String(value || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
return normalized.includes("webhook")
|
||||
|| normalized === "hook"
|
||||
|| normalized === "hooks"
|
||||
|| normalized.endsWith("hook")
|
||||
|| normalized.endsWith("hooks");
|
||||
}
|
||||
|
||||
function redactSnapshotUrl(value, redactedLabel, parentKey) {
|
||||
const withoutCredentials = value.replace(
|
||||
/^((?:[a-z][a-z0-9+.-]*:)*\/\/)([^/?#\s]*@)/i,
|
||||
(match, prefix) => `${prefix}${redactedLabel}@`
|
||||
);
|
||||
const suffixIndex = withoutCredentials.search(/[?#]/);
|
||||
const base = suffixIndex < 0 ? withoutCredentials : withoutCredentials.slice(0, suffixIndex);
|
||||
const redactedBase = redactSnapshotCapabilityPath(base, redactedLabel, parentKey);
|
||||
return suffixIndex < 0
|
||||
? redactedBase
|
||||
: `${redactedBase}${withoutCredentials[suffixIndex]}${redactedLabel}`;
|
||||
}
|
||||
|
||||
function redactSnapshotCapabilityPath(value, redactedLabel, parentKey) {
|
||||
const absoluteUrl = value.match(/^((?:[a-z][a-z0-9+.-]*:)*\/\/)([^/?#]*)([^?#]*)$/i);
|
||||
if (!absoluteUrl) {
|
||||
return redactGenericCapabilityPath(value, redactedLabel, parentKey);
|
||||
}
|
||||
|
||||
const [, scheme, authority, path = ""] = absoluteUrl;
|
||||
const hostWithPort = authority.slice(authority.lastIndexOf("@") + 1);
|
||||
const host = hostWithPort.replace(/:\d+$/, "").toLowerCase();
|
||||
let redactedPath = path;
|
||||
let providerMatched = false;
|
||||
|
||||
if (host === "hooks.slack.com" || host === "hooks.slack-gov.com") {
|
||||
redactedPath = redactedPath.replace(
|
||||
/^(\/services\/[^/]+\/[^/]+\/)([^/]+)(.*)$/i,
|
||||
(match, prefix, secret, suffix) => `${prefix}${redactedLabel}${suffix}`
|
||||
);
|
||||
providerMatched = redactedPath !== path;
|
||||
} else if (
|
||||
host === "discord.com"
|
||||
|| host.endsWith(".discord.com")
|
||||
|| host === "discordapp.com"
|
||||
|| host.endsWith(".discordapp.com")
|
||||
) {
|
||||
redactedPath = redactedPath.replace(
|
||||
/^(\/api(?:\/v\d+)?\/webhooks\/[^/]+\/)([^/]+)(.*)$/i,
|
||||
(match, prefix, secret, suffix) => `${prefix}${redactedLabel}${suffix}`
|
||||
);
|
||||
providerMatched = redactedPath !== path;
|
||||
} else if (host === "api.telegram.org") {
|
||||
redactedPath = redactedPath.replace(
|
||||
/^((?:\/file)?\/bot[^/:]+:)([^/]+)(.*)$/i,
|
||||
(match, prefix, secret, suffix) => `${prefix}${redactedLabel}${suffix}`
|
||||
);
|
||||
providerMatched = redactedPath !== path;
|
||||
}
|
||||
|
||||
if (!providerMatched) {
|
||||
redactedPath = redactGenericCapabilityPath(redactedPath, redactedLabel, parentKey);
|
||||
}
|
||||
|
||||
return `${scheme}${authority}${redactedPath}`;
|
||||
}
|
||||
|
||||
function redactGenericCapabilityPath(path, redactedLabel, parentKey) {
|
||||
const markerRedacted = path.replace(
|
||||
/(^|\/)(webhooks?|hooks?)(\/)(.+)$/i,
|
||||
(match, boundary, marker, separator) => `${boundary}${marker}${separator}${redactedLabel}`
|
||||
);
|
||||
if (markerRedacted !== path) {
|
||||
return markerRedacted;
|
||||
}
|
||||
if (!isWebhookSemanticKey(parentKey)) {
|
||||
return path;
|
||||
}
|
||||
return path.replace(
|
||||
/^(.*\/)([^/]+)(\/?)$/,
|
||||
(match, prefix, secret, suffix) => `${prefix}${redactedLabel}${suffix}`
|
||||
);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable test/no-import-node-test -- zero-dependency security regression gate */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
hasValidCurrentStateToken,
|
||||
normalizeSnapshotOrderedValue,
|
||||
redactSnapshotDisplayValue,
|
||||
SNAPSHOT_SECRET_REDACTED
|
||||
} from "./agentSnapshotDisplayUtils.mjs";
|
||||
|
||||
test("accepts only non-empty opaque current-state tokens", () => {
|
||||
assert.equal(hasValidCurrentStateToken("state-token"), true);
|
||||
assert.equal(hasValidCurrentStateToken(" state-token "), true);
|
||||
assert.equal(hasValidCurrentStateToken(""), false);
|
||||
assert.equal(hasValidCurrentStateToken(" "), false);
|
||||
assert.equal(hasValidCurrentStateToken(null), false);
|
||||
assert.equal(hasValidCurrentStateToken(123), false);
|
||||
});
|
||||
|
||||
test("redacts nested credentials without hiding non-sensitive display values", () => {
|
||||
const redacted = redactSnapshotDisplayValue({
|
||||
apiKey: "api-secret",
|
||||
headers: [
|
||||
{ key: "Authorization", value: "Bearer secret" },
|
||||
{ key: "Proxy-Authorization", value: "Basic secret" },
|
||||
{ key: "Set-Cookie", value: "session=secret" },
|
||||
{ key: "Content-Type", value: "application/json" }
|
||||
],
|
||||
marker: SNAPSHOT_SECRET_REDACTED,
|
||||
session_id: "session-secret",
|
||||
url: "https://user:pass@example.com/context?access_token=secret#fragment",
|
||||
visible: "kept"
|
||||
}, "[hidden]");
|
||||
|
||||
assert.deepEqual(redacted, {
|
||||
apiKey: "[hidden]",
|
||||
headers: [
|
||||
{ key: "Authorization", value: "[hidden]" },
|
||||
{ key: "Proxy-Authorization", value: "[hidden]" },
|
||||
{ key: "Set-Cookie", value: "[hidden]" },
|
||||
{ key: "Content-Type", value: "application/json" }
|
||||
],
|
||||
marker: "[hidden]",
|
||||
session_id: "[hidden]",
|
||||
url: "https://[hidden]@example.com/context?[hidden]",
|
||||
visible: "kept"
|
||||
});
|
||||
});
|
||||
|
||||
test("uses a function parameter key when redacting scalar plugin values", () => {
|
||||
assert.equal(redactSnapshotDisplayValue("secret", "[hidden]", "clientSecret"), "[hidden]");
|
||||
assert.equal(redactSnapshotDisplayValue("secret", "[hidden]", "Ocp-Apim-Subscription-Key"), "[hidden]");
|
||||
assert.equal(redactSnapshotDisplayValue("public", "[hidden]", "clientId"), "public");
|
||||
});
|
||||
|
||||
test("redacts mixed-case name/value credential entries", () => {
|
||||
assert.deepEqual(redactSnapshotDisplayValue({
|
||||
headers: [
|
||||
{ NAME: "Authorization", VALUE: "Bearer live-secret" },
|
||||
{ NaMe: "Content-Type", VaLuE: "application/json" }
|
||||
]
|
||||
}, "[hidden]"), {
|
||||
headers: [
|
||||
{ NAME: "Authorization", VALUE: "[hidden]" },
|
||||
{ NaMe: "Content-Type", VaLuE: "application/json" }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
test("redacts capability URL path secrets while preserving public route identity", () => {
|
||||
const redacted = redactSnapshotDisplayValue({
|
||||
slackUrl: "https://hooks.slack.com/services/T00000000/B00000000/slack-secret",
|
||||
slackGovUrl: "https://hooks.slack-gov.com/services/T00000000/B00000000/slack-gov-secret",
|
||||
discordEndpoint: "https://discord.com/api/webhooks/123456789/discord-secret",
|
||||
telegramUri: "https://api.telegram.org/bot123456789:telegram-secret/sendMessage",
|
||||
telegramFileUri: "https://api.telegram.org/file/bot123456789:telegram-file-secret/documents/file.txt",
|
||||
callbackUrl: "https://events.example.com/api/webhook/generic-secret/delivery",
|
||||
hooksEndpoint: "https://events.example.com/v1/hooks/hook-secret/status",
|
||||
hookEndpoint: "https://events.example.com/v1/hook/singular-secret/status",
|
||||
webhooksEndpoint: "https://events.example.com/v1/webhooks/plural-secret/status",
|
||||
relativeUrl: "/api/webhooks/relative-secret/continuation",
|
||||
nested: {
|
||||
deliveryWebhook: {
|
||||
options: {
|
||||
target: "https://events.example.com/incoming/nested-secret",
|
||||
relativeTarget: "/incoming/nested-relative-secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
protocolRelativeUrl: "//protocol-user:protocol-pass@example.com/context",
|
||||
jdbcUrl: "jdbc:mysql://jdbc-user:jdbc-pass@example.com/database",
|
||||
webhook: "https://capability.example.com/public-prefix/key-secret",
|
||||
restUrl: "https://api.example.com/v1/users/customer-123",
|
||||
similarMarkerUrl: "https://api.example.com/v1/webhook-settings/public-value"
|
||||
}, "[hidden]");
|
||||
|
||||
assert.deepEqual(redacted, {
|
||||
slackUrl: "https://hooks.slack.com/services/T00000000/B00000000/[hidden]",
|
||||
slackGovUrl: "https://hooks.slack-gov.com/services/T00000000/B00000000/[hidden]",
|
||||
discordEndpoint: "https://discord.com/api/webhooks/123456789/[hidden]",
|
||||
telegramUri: "https://api.telegram.org/bot123456789:[hidden]/sendMessage",
|
||||
telegramFileUri: "https://api.telegram.org/file/bot123456789:[hidden]/documents/file.txt",
|
||||
callbackUrl: "https://events.example.com/api/webhook/[hidden]",
|
||||
hooksEndpoint: "https://events.example.com/v1/hooks/[hidden]",
|
||||
hookEndpoint: "https://events.example.com/v1/hook/[hidden]",
|
||||
webhooksEndpoint: "https://events.example.com/v1/webhooks/[hidden]",
|
||||
relativeUrl: "/api/webhooks/[hidden]",
|
||||
nested: {
|
||||
deliveryWebhook: {
|
||||
options: {
|
||||
target: "https://events.example.com/incoming/[hidden]",
|
||||
relativeTarget: "/incoming/[hidden]"
|
||||
}
|
||||
}
|
||||
},
|
||||
protocolRelativeUrl: "//[hidden]@example.com/context",
|
||||
jdbcUrl: "jdbc:mysql://[hidden]@example.com/database",
|
||||
webhook: "https://capability.example.com/public-prefix/[hidden]",
|
||||
restUrl: "https://api.example.com/v1/users/customer-123",
|
||||
similarMarkerUrl: "https://api.example.com/v1/webhook-settings/public-value"
|
||||
});
|
||||
assert.equal(JSON.stringify(redacted).includes("slack-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("slack-gov-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("discord-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("telegram-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("telegram-file-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("generic-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("hook-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("singular-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("plural-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("relative-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("nested-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("key-secret"), false);
|
||||
});
|
||||
|
||||
test("normalizes object keys without erasing ordered-list changes", () => {
|
||||
const before = [
|
||||
{ url: "https://first.example", headers: { Zeta: "2", Accept: "json" } },
|
||||
{ url: "https://second.example" }
|
||||
];
|
||||
const sameOrder = [
|
||||
{ headers: { Accept: "json", Zeta: "2" }, url: "https://first.example" },
|
||||
{ url: "https://second.example" }
|
||||
];
|
||||
const reversed = [...sameOrder].reverse();
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeSnapshotOrderedValue(before),
|
||||
normalizeSnapshotOrderedValue(sameOrder)
|
||||
);
|
||||
assert.notDeepEqual(
|
||||
normalizeSnapshotOrderedValue(before),
|
||||
normalizeSnapshotOrderedValue(reversed)
|
||||
);
|
||||
});
|
||||
@@ -845,7 +845,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': 'Vor der Wiederherstellung',
|
||||
'agentSnapshot.afterRestore': 'Nach der Wiederherstellung',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Beim Wiederherstellen einer Version ohne Speicher wird der Chatverlauf dieses Agenten gelöscht. Fahren Sie nur fort, wenn Sie dieses Risiko bestätigt haben.',
|
||||
'agentSnapshot.restoreConfirm': 'Version {version} wiederherstellen? Die aktuelle Konfiguration wird zuerst als neue Version gespeichert.',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': 'Diese Wiederherstellung löscht den bestehenden Chatverlauf dieses Agenten dauerhaft. Chatverläufe sind nicht in Konfigurations-Snapshots enthalten und können nicht über den Versionsverlauf wiederhergestellt werden.',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Nicht gespeicherte Änderungen auf dieser Seite werden nicht in den Verlauf übernommen und nach der Wiederherstellung verworfen.',
|
||||
'agentSnapshot.restoreConfirm': 'Version {version} wiederherstellen? Die aktuelle Konfiguration bleibt im Verlauf erhalten.',
|
||||
'agentSnapshot.restoreSuccess': 'Version wurde wiederhergestellt',
|
||||
'agentSnapshot.restoreFailed': 'Version konnte nicht wiederhergestellt werden',
|
||||
'agentSnapshot.restoreFailedRollback': 'Version konnte nicht wiederhergestellt werden. Die Transaktion wurde zurueckgerollt und die aktuelle Konfiguration wurde nicht geaendert',
|
||||
@@ -854,10 +856,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': 'Verlaufsversion konnte nicht gelöscht werden',
|
||||
'agentSnapshot.fetchFailed': 'Versionsverlauf konnte nicht abgerufen werden',
|
||||
'agentSnapshot.detailFailed': 'Snapshot-Details konnten nicht abgerufen werden',
|
||||
'agentSnapshot.currentVersion': 'Aktuelle Version',
|
||||
'agentSnapshot.currentVersion': 'Neuester Snapshot',
|
||||
'agentSnapshot.source.config': 'Konfiguration gespeichert',
|
||||
'agentSnapshot.source.current': 'Aktuelle Konfiguration',
|
||||
'agentSnapshot.source.restore': 'Vor der Wiederherstellung',
|
||||
'agentSnapshot.source.restore': 'Wiederherstellungsergebnis',
|
||||
'agentSnapshot.source.initial': 'Initialversion',
|
||||
'agentSnapshot.field.restore': 'Wiederherstellung',
|
||||
'agentSnapshot.field.initial': 'Initialer Snapshot',
|
||||
|
||||
@@ -895,7 +895,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': 'Before Restore',
|
||||
'agentSnapshot.afterRestore': 'After Restore',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Restoring to a no-memory version will clear this agent\'s chat history. Continue only after confirming this risk.',
|
||||
'agentSnapshot.restoreConfirm': 'Restore to version {version}? The current configuration will be saved as a new version first.',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': 'This restore will permanently delete this agent\'s existing chat history. Chat history is not included in configuration snapshots and cannot be recovered from version history.',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Unsaved changes on this page are not included in history and will be discarded after restore.',
|
||||
'agentSnapshot.restoreConfirm': 'Restore to version {version}? The current configuration will be preserved in history.',
|
||||
'agentSnapshot.restoreSuccess': 'Version restored',
|
||||
'agentSnapshot.restoreFailed': 'Failed to restore version',
|
||||
'agentSnapshot.restoreFailedRollback': 'Failed to restore version. The transaction was rolled back and the current configuration was not changed',
|
||||
@@ -904,10 +906,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': 'Failed to delete history version',
|
||||
'agentSnapshot.fetchFailed': 'Failed to fetch versions',
|
||||
'agentSnapshot.detailFailed': 'Failed to fetch snapshot details',
|
||||
'agentSnapshot.currentVersion': 'Current Version',
|
||||
'agentSnapshot.currentVersion': 'Latest Snapshot',
|
||||
'agentSnapshot.source.config': 'Config Save',
|
||||
'agentSnapshot.source.current': 'Current Config',
|
||||
'agentSnapshot.source.restore': 'Before Restore',
|
||||
'agentSnapshot.source.restore': 'Restored',
|
||||
'agentSnapshot.source.initial': 'Initial Version',
|
||||
'agentSnapshot.field.restore': 'Restore',
|
||||
'agentSnapshot.field.initial': 'Initial Snapshot',
|
||||
|
||||
@@ -845,7 +845,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': 'Antes da Restauração',
|
||||
'agentSnapshot.afterRestore': 'Depois da Restauração',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Restaurar para uma versão sem memória apagará o histórico de conversa deste agente. Continue somente após confirmar esse risco.',
|
||||
'agentSnapshot.restoreConfirm': 'Restaurar para a versão {version}? A configuração atual será salva primeiro como uma nova versão.',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': 'Esta restauração excluirá permanentemente o histórico de conversa existente deste agente. O histórico de conversa não faz parte dos snapshots de configuração e não pode ser recuperado pelo histórico de versões.',
|
||||
'agentSnapshot.unsavedChangesWarning': 'As alterações não salvas nesta página não serão incluídas no histórico e serão descartadas após a restauração.',
|
||||
'agentSnapshot.restoreConfirm': 'Restaurar para a versão {version}? A configuração atual será preservada no histórico.',
|
||||
'agentSnapshot.restoreSuccess': 'Versão restaurada',
|
||||
'agentSnapshot.restoreFailed': 'Falha ao restaurar versão',
|
||||
'agentSnapshot.restoreFailedRollback': 'Falha ao restaurar versão. A transação foi revertida e a configuração atual não foi alterada',
|
||||
@@ -854,10 +856,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': 'Falha ao excluir a versão do histórico',
|
||||
'agentSnapshot.fetchFailed': 'Falha ao buscar versões',
|
||||
'agentSnapshot.detailFailed': 'Falha ao buscar detalhes do snapshot',
|
||||
'agentSnapshot.currentVersion': 'Versão Atual',
|
||||
'agentSnapshot.currentVersion': 'Snapshot mais recente',
|
||||
'agentSnapshot.source.config': 'Configuração Salva',
|
||||
'agentSnapshot.source.current': 'Configuração Atual',
|
||||
'agentSnapshot.source.restore': 'Antes da Restauração',
|
||||
'agentSnapshot.source.restore': 'Resultado da restauração',
|
||||
'agentSnapshot.source.initial': 'Versão inicial',
|
||||
'agentSnapshot.field.restore': 'Restauração',
|
||||
'agentSnapshot.field.initial': 'Snapshot inicial',
|
||||
|
||||
@@ -845,7 +845,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': 'Trước khi khôi phục',
|
||||
'agentSnapshot.afterRestore': 'Sau khi khôi phục',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Khôi phục về phiên bản không có bộ nhớ sẽ xóa lịch sử trò chuyện của tác nhân này. Chỉ tiếp tục sau khi xác nhận rủi ro này.',
|
||||
'agentSnapshot.restoreConfirm': 'Khôi phục về phiên bản {version}? Cấu hình hiện tại sẽ được lưu thành phiên bản mới trước.',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': 'Lần khôi phục này sẽ xóa vĩnh viễn lịch sử trò chuyện hiện có của tác nhân. Lịch sử trò chuyện không nằm trong ảnh chụp cấu hình và không thể khôi phục từ lịch sử phiên bản.',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Các thay đổi chưa lưu trên trang này không được ghi vào lịch sử và sẽ bị loại bỏ sau khi khôi phục.',
|
||||
'agentSnapshot.restoreConfirm': 'Khôi phục về phiên bản {version}? Cấu hình hiện tại sẽ được giữ lại trong lịch sử.',
|
||||
'agentSnapshot.restoreSuccess': 'Đã khôi phục phiên bản',
|
||||
'agentSnapshot.restoreFailed': 'Khôi phục phiên bản thất bại',
|
||||
'agentSnapshot.restoreFailedRollback': 'Khôi phục phiên bản thất bại. Giao dịch đã được hoàn tác và cấu hình hiện tại chưa thay đổi',
|
||||
@@ -854,10 +856,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': 'Xóa phiên bản lịch sử thất bại',
|
||||
'agentSnapshot.fetchFailed': 'Không thể tải lịch sử phiên bản',
|
||||
'agentSnapshot.detailFailed': 'Không thể tải chi tiết snapshot',
|
||||
'agentSnapshot.currentVersion': 'Phiên bản hiện tại',
|
||||
'agentSnapshot.currentVersion': 'Ảnh chụp mới nhất',
|
||||
'agentSnapshot.source.config': 'Lưu cấu hình',
|
||||
'agentSnapshot.source.current': 'Cấu hình hiện tại',
|
||||
'agentSnapshot.source.restore': 'Trước khi khôi phục',
|
||||
'agentSnapshot.source.restore': 'Kết quả khôi phục',
|
||||
'agentSnapshot.source.initial': 'Phiên bản ban đầu',
|
||||
'agentSnapshot.field.restore': 'Khôi phục',
|
||||
'agentSnapshot.field.initial': 'Snapshot ban đầu',
|
||||
|
||||
@@ -895,7 +895,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': '恢复前',
|
||||
'agentSnapshot.afterRestore': '恢复后',
|
||||
'agentSnapshot.restoreMemoryWarning': '恢复到无记忆版本会清空该智能体的聊天记录,请确认后再继续。',
|
||||
'agentSnapshot.restoreConfirm': '确定恢复到版本 {version} 吗?当前配置会先自动保存为新的历史版本。',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': '此次恢复会永久删除该智能体现有的聊天记录。聊天记录不包含在配置快照中,删除后无法通过历史版本恢复。',
|
||||
'agentSnapshot.unsavedChangesWarning': '页面中尚未保存的修改不会进入历史,并会在恢复后丢失。',
|
||||
'agentSnapshot.restoreConfirm': '确定恢复到版本 {version} 吗?当前配置会保留在历史中。',
|
||||
'agentSnapshot.restoreSuccess': '已恢复历史版本',
|
||||
'agentSnapshot.restoreFailed': '恢复历史版本失败',
|
||||
'agentSnapshot.restoreFailedRollback': '恢复历史版本失败,事务已回滚,当前配置未改变',
|
||||
@@ -904,10 +906,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': '删除历史版本失败',
|
||||
'agentSnapshot.fetchFailed': '获取历史版本失败',
|
||||
'agentSnapshot.detailFailed': '获取快照详情失败',
|
||||
'agentSnapshot.currentVersion': '当前版本',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '当前配置',
|
||||
'agentSnapshot.source.restore': '恢复前',
|
||||
'agentSnapshot.source.restore': '恢复结果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.restore': '恢复操作',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
|
||||
@@ -845,7 +845,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': '恢復前',
|
||||
'agentSnapshot.afterRestore': '恢復後',
|
||||
'agentSnapshot.restoreMemoryWarning': '恢復到無記憶版本會清空該智能體的聊天記錄,請確認後再繼續。',
|
||||
'agentSnapshot.restoreConfirm': '確定恢復到版本 {version} 嗎?目前配置會先自動保存為新的歷史版本。',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': '此次恢復會永久刪除該智能體現有的聊天記錄。聊天記錄不包含在配置快照中,刪除後無法透過歷史版本復原。',
|
||||
'agentSnapshot.unsavedChangesWarning': '頁面中尚未儲存的修改不會寫入歷史,並會在恢復後遺失。',
|
||||
'agentSnapshot.restoreConfirm': '確定恢復到版本 {version} 嗎?目前配置會保留在歷史記錄中。',
|
||||
'agentSnapshot.restoreSuccess': '已恢復歷史版本',
|
||||
'agentSnapshot.restoreFailed': '恢復歷史版本失敗',
|
||||
'agentSnapshot.restoreFailedRollback': '恢復歷史版本失敗,交易已回滾,目前配置未改變',
|
||||
@@ -854,10 +856,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': '刪除歷史版本失敗',
|
||||
'agentSnapshot.fetchFailed': '獲取歷史版本失敗',
|
||||
'agentSnapshot.detailFailed': '獲取快照詳情失敗',
|
||||
'agentSnapshot.currentVersion': '目前版本',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '目前配置',
|
||||
'agentSnapshot.source.restore': '恢復前',
|
||||
'agentSnapshot.source.restore': '恢復結果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.restore': '恢復操作',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<h2 class="page-title">{{ $t("roleConfig.title") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="main-wrapper" v-loading="agentReloading">
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="config-card" shadow="never">
|
||||
@@ -54,7 +54,7 @@
|
||||
<el-button
|
||||
type="primary"
|
||||
class="save-btn"
|
||||
:disabled="voiceOptionsLoading"
|
||||
:disabled="configInteractionBlocked"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ $t("roleConfig.saveConfig") }}
|
||||
@@ -304,6 +304,7 @@
|
||||
<el-select
|
||||
v-model="form.model[model.key]"
|
||||
filterable
|
||||
:disabled="model.type === 'TTS' && voiceOptionsLoading"
|
||||
:placeholder="$t('roleConfig.pleaseSelect')"
|
||||
class="form-select"
|
||||
@change="handleModelChange(model.type, $event)"
|
||||
@@ -370,6 +371,7 @@
|
||||
<div class="model-select-wrapper">
|
||||
<el-select
|
||||
v-model="selectedLanguage"
|
||||
:disabled="voiceOptionsLoading"
|
||||
:placeholder="$t('roleConfig.selectLanguage')"
|
||||
class="form-select language-select"
|
||||
@change="handleLanguageChange"
|
||||
@@ -395,6 +397,7 @@
|
||||
<el-select
|
||||
v-model="form.ttsVoiceId"
|
||||
filterable
|
||||
:disabled="voiceOptionsLoading"
|
||||
:placeholder="$t('roleConfig.pleaseSelect')"
|
||||
class="form-select"
|
||||
@change="handleVoiceChange"
|
||||
@@ -566,6 +569,18 @@ export default {
|
||||
ttsVoiceTouched: false,
|
||||
voiceFetchSeq: 0,
|
||||
voiceOptionsLoading: false,
|
||||
lastValidTtsDraft: null,
|
||||
agentReloading: false,
|
||||
agentReloadSeq: 0,
|
||||
agentConfigFetchSeq: 0,
|
||||
agentTagsFetchSeq: 0,
|
||||
currentVersionFetchSeq: 0,
|
||||
agentConfigLoaded: false,
|
||||
agentFunctionsLoaded: false,
|
||||
agentTagsLoaded: false,
|
||||
currentVersionLoaded: false,
|
||||
pluginMetadataReady: false,
|
||||
pluginMetadataLoading: null,
|
||||
// 功能状态
|
||||
featureStatus: {
|
||||
vad: false, // 语言检测活动功能状态
|
||||
@@ -578,6 +593,15 @@ export default {
|
||||
checkedReplacementWordIds: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
configInteractionBlocked() {
|
||||
return this.agentReloading
|
||||
|| this.voiceOptionsLoading
|
||||
|| !this.agentConfigLoaded
|
||||
|| !this.agentFunctionsLoaded
|
||||
|| !this.agentTagsLoaded;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToHome() {
|
||||
this.$router.push("/home");
|
||||
@@ -602,7 +626,7 @@ export default {
|
||||
return { ...fallback };
|
||||
},
|
||||
async saveConfig() {
|
||||
if (this.voiceOptionsLoading) {
|
||||
if (this.configInteractionBlocked) {
|
||||
return;
|
||||
}
|
||||
const configData = {
|
||||
@@ -676,6 +700,9 @@ export default {
|
||||
&& this.form.ttsVoiceId === submittedTtsVoiceId) {
|
||||
this.ttsVoiceTouched = false;
|
||||
}
|
||||
if (submittedVoiceFetchSeq === this.voiceFetchSeq) {
|
||||
this.lastValidTtsDraft = this.captureTtsDraft();
|
||||
}
|
||||
this.$message.success({
|
||||
message: i18n.t("roleConfig.saveSuccess"),
|
||||
showClose: true,
|
||||
@@ -692,24 +719,77 @@ export default {
|
||||
});
|
||||
|
||||
},
|
||||
async reloadAgentPage(agentId, options = {}) {
|
||||
if (!agentId) {
|
||||
return false;
|
||||
}
|
||||
const requestSeq = ++this.agentReloadSeq;
|
||||
this.agentReloading = true;
|
||||
if (options.closeEditors) {
|
||||
this.showSnapshotDialog = false;
|
||||
this.showFunctionDialog = false;
|
||||
this.showContextProviderDialog = false;
|
||||
this.showTtsAdvancedDialog = false;
|
||||
this.inputVisible = false;
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
this.fetchAgentConfig(agentId, { showError: false }),
|
||||
this.getAgentTags(agentId, { showError: false }),
|
||||
this.fetchCurrentVersion(agentId, { showError: false })
|
||||
]);
|
||||
if (requestSeq !== this.agentReloadSeq) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.agentReloading = false;
|
||||
if (!this.pluginMetadataReady && this.agentConfigLoaded) {
|
||||
this.$message.error(i18n.t("roleConfig.fetchPluginsFailed"));
|
||||
} else if (!results.every(Boolean)) {
|
||||
this.$message.error(i18n.t("roleConfig.fetchConfigFailed"));
|
||||
}
|
||||
return results.every(Boolean);
|
||||
},
|
||||
handleSnapshotRestored() {
|
||||
const agentId = this.$route.query.agentId;
|
||||
if (agentId) {
|
||||
this.fetchAgentConfig(agentId);
|
||||
this.getAgentTags(agentId);
|
||||
this.fetchCurrentVersion(agentId);
|
||||
this.reloadAgentPage(agentId, { closeEditors: true });
|
||||
}
|
||||
},
|
||||
fetchCurrentVersion(agentId) {
|
||||
fetchCurrentVersion(agentId, options = {}) {
|
||||
const requestSeq = ++this.currentVersionFetchSeq;
|
||||
this.currentVersionLoaded = false;
|
||||
if (!agentId) {
|
||||
this.currentVersionNo = null;
|
||||
return;
|
||||
this.currentVersionLoaded = true;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
Api.agent.getDeviceConfig(agentId, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.currentVersionNo = data.data?.currentVersionNo || null;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const handleFailure = (error) => {
|
||||
if (requestSeq !== this.currentVersionFetchSeq) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
this.currentVersionLoaded = false;
|
||||
if (options.showError !== false) {
|
||||
this.$message.error(error?.data?.msg || i18n.t("roleConfig.fetchConfigFailed"));
|
||||
}
|
||||
resolve(false);
|
||||
};
|
||||
Api.agent.getDeviceConfig(agentId, ({ data }) => {
|
||||
if (requestSeq !== this.currentVersionFetchSeq) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (data?.code === 0) {
|
||||
this.currentVersionNo = data.data?.currentVersionNo || null;
|
||||
this.currentVersionLoaded = true;
|
||||
resolve(true);
|
||||
} else {
|
||||
handleFailure(data);
|
||||
}
|
||||
}, handleFailure);
|
||||
});
|
||||
},
|
||||
resetConfig() {
|
||||
@@ -783,6 +863,7 @@ export default {
|
||||
}
|
||||
},
|
||||
applyTemplateData(templateData) {
|
||||
const rollbackState = this.cloneTtsDraft(this.lastValidTtsDraft) || this.captureTtsDraft();
|
||||
const currentLanguage = this.selectedLanguage;
|
||||
this.form = {
|
||||
...this.form,
|
||||
@@ -805,90 +886,151 @@ export default {
|
||||
};
|
||||
if (templateData.ttsLanguage) {
|
||||
this.selectedLanguage = templateData.ttsLanguage;
|
||||
this.ttsLanguageTouched = true;
|
||||
}
|
||||
if (templateData.ttsModelId || templateData.ttsVoiceId || templateData.ttsLanguage) {
|
||||
if (templateData.ttsModelId || templateData.ttsVoiceId) {
|
||||
this.ttsVoiceTouched = true;
|
||||
}
|
||||
this.ttsLanguageTouched = true;
|
||||
this.fetchVoiceOptions(this.form.model.ttsModelId, {
|
||||
autoSelectVoice: true,
|
||||
preferredLanguage: templateData.ttsLanguage || (templateData.ttsVoiceId ? "" : currentLanguage),
|
||||
preferredVoiceId: templateData.ttsVoiceId || ""
|
||||
preferredVoiceId: templateData.ttsVoiceId || "",
|
||||
rollbackState,
|
||||
markTouched: true
|
||||
});
|
||||
}
|
||||
},
|
||||
fetchAgentConfig(agentId) {
|
||||
Api.agent.getDeviceConfig(agentId, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.tempSummaryMemory = "";
|
||||
this.ttsLanguageTouched = false;
|
||||
this.ttsVoiceTouched = false;
|
||||
this.form = {
|
||||
...this.form,
|
||||
...data.data,
|
||||
model: {
|
||||
ttsModelId: data.data.ttsModelId,
|
||||
vadModelId: data.data.vadModelId,
|
||||
asrModelId: data.data.asrModelId,
|
||||
llmModelId: data.data.llmModelId,
|
||||
slmModelId: data.data.slmModelId,
|
||||
vllmModelId: data.data.vllmModelId,
|
||||
memModelId: data.data.memModelId,
|
||||
intentModelId: data.data.intentModelId,
|
||||
},
|
||||
buildCurrentFunctions(savedMappings) {
|
||||
if (!Array.isArray(savedMappings)) {
|
||||
throw new TypeError("Invalid agent function mappings");
|
||||
}
|
||||
return savedMappings.map((mapping) => {
|
||||
const pluginId = mapping.pluginId || mapping.id;
|
||||
const meta = this.pluginMetadataReady
|
||||
? this.allFunctions.find((item) => item.id === pluginId)
|
||||
: null;
|
||||
return {
|
||||
id: pluginId,
|
||||
name: meta?.name || mapping.name || pluginId,
|
||||
params: this.normalizeFunctionParams(mapping.paramInfo ?? mapping.params, meta?.params || {}),
|
||||
fieldsMeta: meta?.fieldsMeta || []
|
||||
};
|
||||
}).filter((item) => item.id);
|
||||
},
|
||||
enrichCurrentFunctionsWithMetadata() {
|
||||
if (!this.agentFunctionsLoaded || !this.pluginMetadataReady) {
|
||||
return;
|
||||
}
|
||||
this.currentFunctions = this.currentFunctions.map((item) => {
|
||||
const meta = this.allFunctions.find((candidate) => candidate.id === item.id);
|
||||
if (!meta) {
|
||||
return {
|
||||
...item,
|
||||
params: this.normalizeFunctionParams(item.params),
|
||||
fieldsMeta: item.fieldsMeta || []
|
||||
};
|
||||
this.fetchVoiceOptions(data.data.ttsModelId, {
|
||||
preferredLanguage: data.data.ttsLanguage,
|
||||
preferredVoiceId: data.data.ttsVoiceId
|
||||
});
|
||||
|
||||
// 同步TTS设置到ttsSettings
|
||||
this.ttsSettings = {
|
||||
volume: this.form.ttsVolume || 0,
|
||||
speed: this.form.ttsRate || 0,
|
||||
pitch: this.form.ttsPitch || 0
|
||||
};
|
||||
// 同步替换词到checkedReplacementWordIds
|
||||
this.checkedReplacementWordIds = data.data.correctWordFileIds || [];
|
||||
|
||||
// 后端只给了最小映射:[{ id, agentId, pluginId }, ...]
|
||||
const savedMappings = data.data.functions || [];
|
||||
|
||||
// 加载上下文配置
|
||||
this.currentContextProviders = data.data.contextProviders || [];
|
||||
|
||||
// 先保证 allFunctions 已经加载(如果没有,则先 fetchAllFunctions)
|
||||
const ensureFuncs = this.allFunctions.length
|
||||
? Promise.resolve()
|
||||
: this.fetchAllFunctions();
|
||||
|
||||
ensureFuncs.then(() => {
|
||||
// 合并:按照 pluginId(id 字段)把全量元数据信息补齐
|
||||
this.currentFunctions = savedMappings.map((mapping) => {
|
||||
const meta = this.allFunctions.find((f) => f.id === mapping.pluginId);
|
||||
if (!meta) {
|
||||
// 插件定义没找到,退化处理
|
||||
return { id: mapping.pluginId, name: mapping.pluginId, params: {} };
|
||||
}
|
||||
return {
|
||||
id: mapping.pluginId,
|
||||
name: meta.name,
|
||||
// 后端如果还有 paramInfo 字段就用 mapping.paramInfo,否则用 meta.params 默认值
|
||||
params: this.normalizeFunctionParams(mapping.paramInfo, meta.params),
|
||||
fieldsMeta: meta.fieldsMeta, // 保留以便对话框渲染 tooltip
|
||||
};
|
||||
});
|
||||
// 备份原始,以备取消时恢复
|
||||
this.originalFunctions = JSON.parse(JSON.stringify(this.currentFunctions));
|
||||
|
||||
// 确保意图识别选项的可见性正确
|
||||
this.updateIntentOptionsVisibility();
|
||||
});
|
||||
} else {
|
||||
this.$message.error(data.msg || i18n.t("roleConfig.fetchConfigFailed"));
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
name: meta.name || item.name || item.id,
|
||||
params: this.normalizeFunctionParams(item.params, meta.params),
|
||||
fieldsMeta: meta.fieldsMeta || []
|
||||
};
|
||||
});
|
||||
this.originalFunctions = JSON.parse(JSON.stringify(this.currentFunctions));
|
||||
},
|
||||
fetchAgentConfig(agentId, options = {}) {
|
||||
const requestSeq = ++this.agentConfigFetchSeq;
|
||||
this.agentConfigLoaded = false;
|
||||
this.agentFunctionsLoaded = false;
|
||||
this.voiceFetchSeq += 1;
|
||||
this.voiceOptionsLoading = false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const handleFailure = (error) => {
|
||||
if (requestSeq !== this.agentConfigFetchSeq) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
this.agentConfigLoaded = false;
|
||||
this.agentFunctionsLoaded = false;
|
||||
if (options.showError !== false) {
|
||||
this.$message.error(error?.data?.msg || i18n.t("roleConfig.fetchConfigFailed"));
|
||||
}
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
Api.agent.getDeviceConfig(agentId, ({ data }) => {
|
||||
if (requestSeq !== this.agentConfigFetchSeq) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (data?.code !== 0 || !data.data) {
|
||||
handleFailure(data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const agentData = data.data;
|
||||
if (agentData.functions != null && !Array.isArray(agentData.functions)) {
|
||||
throw new TypeError("Invalid agent function mappings");
|
||||
}
|
||||
if (agentData.contextProviders != null && !Array.isArray(agentData.contextProviders)) {
|
||||
throw new TypeError("Invalid context providers");
|
||||
}
|
||||
if (agentData.correctWordFileIds != null && !Array.isArray(agentData.correctWordFileIds)) {
|
||||
throw new TypeError("Invalid correct-word mappings");
|
||||
}
|
||||
this.tempSummaryMemory = "";
|
||||
this.ttsLanguageTouched = false;
|
||||
this.ttsVoiceTouched = false;
|
||||
this.form = {
|
||||
...this.form,
|
||||
...agentData,
|
||||
model: {
|
||||
ttsModelId: agentData.ttsModelId,
|
||||
vadModelId: agentData.vadModelId,
|
||||
asrModelId: agentData.asrModelId,
|
||||
llmModelId: agentData.llmModelId,
|
||||
slmModelId: agentData.slmModelId,
|
||||
vllmModelId: agentData.vllmModelId,
|
||||
memModelId: agentData.memModelId,
|
||||
intentModelId: agentData.intentModelId,
|
||||
},
|
||||
};
|
||||
this.selectedLanguage = agentData.ttsLanguage || "";
|
||||
this.voiceOptions = [];
|
||||
this.voiceDetails = {};
|
||||
this.languageOptions = [];
|
||||
this.lastValidTtsDraft = this.captureTtsDraft();
|
||||
this.fetchVoiceOptions(agentData.ttsModelId, {
|
||||
preferredLanguage: agentData.ttsLanguage,
|
||||
preferredVoiceId: agentData.ttsVoiceId
|
||||
});
|
||||
|
||||
this.ttsSettings = {
|
||||
volume: this.form.ttsVolume || 0,
|
||||
speed: this.form.ttsRate || 0,
|
||||
pitch: this.form.ttsPitch || 0
|
||||
};
|
||||
this.checkedReplacementWordIds = agentData.correctWordFileIds || [];
|
||||
this.currentContextProviders = agentData.contextProviders || [];
|
||||
this.currentFunctions = this.buildCurrentFunctions(agentData.functions || []);
|
||||
this.originalFunctions = JSON.parse(JSON.stringify(this.currentFunctions));
|
||||
this.agentFunctionsLoaded = true;
|
||||
this.agentConfigLoaded = true;
|
||||
|
||||
const metadataPromise = this.pluginMetadataReady
|
||||
? Promise.resolve(true)
|
||||
: this.fetchAllFunctions({ showError: options.showError });
|
||||
metadataPromise.then((metadataReady) => {
|
||||
if (requestSeq === this.agentConfigFetchSeq && metadataReady) {
|
||||
this.enrichCurrentFunctionsWithMetadata();
|
||||
this.updateIntentOptionsVisibility();
|
||||
}
|
||||
resolve(true);
|
||||
}).catch(handleFailure);
|
||||
} catch (error) {
|
||||
handleFailure(error);
|
||||
}
|
||||
}, handleFailure);
|
||||
});
|
||||
},
|
||||
fetchModelOptions() {
|
||||
@@ -942,6 +1084,7 @@ export default {
|
||||
this.voiceDetails = {};
|
||||
this.languageOptions = [];
|
||||
this.selectedLanguage = '';
|
||||
this.lastValidTtsDraft = this.captureTtsDraft();
|
||||
return;
|
||||
}
|
||||
this.voiceOptionsLoading = true;
|
||||
@@ -949,57 +1092,165 @@ export default {
|
||||
if (requestSeq !== this.voiceFetchSeq) {
|
||||
return;
|
||||
}
|
||||
this.voiceOptionsLoading = false;
|
||||
if (data.code === 0 && data.data) {
|
||||
// 保存完整的音色信息
|
||||
this.voiceDetails = data.data.reduce((acc, voice) => {
|
||||
acc[voice.id] = voice;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 提取所有语言选项并去重
|
||||
const allLanguages = new Set();
|
||||
data.data.forEach(voice => {
|
||||
if (voice.languages) {
|
||||
const languagesArray = voice.languages.split(/[、;;,,]/).map(lang => lang.trim()).filter(lang => lang);
|
||||
languagesArray.forEach(lang => allLanguages.add(lang));
|
||||
}
|
||||
});
|
||||
|
||||
this.languageOptions = Array.from(allLanguages).map(lang => ({
|
||||
value: lang,
|
||||
label: lang
|
||||
}));
|
||||
|
||||
// 优先保留调用方指定或后端保存的语言,其次使用当前音色的默认语言
|
||||
const requestedLanguage = options.preferredLanguage;
|
||||
const preferredVoiceLanguage = options.preferredVoiceId
|
||||
? this.getVoiceDefaultLanguage(options.preferredVoiceId)
|
||||
: "";
|
||||
if (requestedLanguage && this.languageOptions.some(option => option.value === requestedLanguage)) {
|
||||
this.selectedLanguage = requestedLanguage;
|
||||
} else if (preferredVoiceLanguage && this.languageOptions.some(option => option.value === preferredVoiceLanguage)) {
|
||||
this.selectedLanguage = preferredVoiceLanguage;
|
||||
} else if (this.form.ttsLanguage && this.languageOptions.some(option => option.value === this.form.ttsLanguage)) {
|
||||
this.selectedLanguage = this.form.ttsLanguage;
|
||||
} else if (this.getVoiceDefaultLanguage(this.form.ttsVoiceId)) {
|
||||
this.selectedLanguage = this.getVoiceDefaultLanguage(this.form.ttsVoiceId);
|
||||
} else if (this.languageOptions.length > 0) {
|
||||
this.selectedLanguage = this.languageOptions[0].value;
|
||||
} else {
|
||||
this.selectedLanguage = "";
|
||||
}
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
this.filterVoicesByLanguage(options);
|
||||
} else {
|
||||
this.voiceOptions = [];
|
||||
this.voiceDetails = {};
|
||||
this.languageOptions = [];
|
||||
this.selectedLanguage = '';
|
||||
const draft = data.code === 0
|
||||
? this.buildTtsDraft(modelId, data.data, options)
|
||||
: null;
|
||||
if (!draft) {
|
||||
this.handleVoiceOptionsFailure(requestSeq, options.rollbackState);
|
||||
return;
|
||||
}
|
||||
this.applyTtsDraft(draft, options);
|
||||
this.voiceOptionsLoading = false;
|
||||
this.lastValidTtsDraft = this.captureTtsDraft();
|
||||
}, () => {
|
||||
this.handleVoiceOptionsFailure(requestSeq, options.rollbackState);
|
||||
});
|
||||
},
|
||||
cloneTtsDraft(draft) {
|
||||
return draft ? JSON.parse(JSON.stringify(draft)) : null;
|
||||
},
|
||||
captureTtsDraft() {
|
||||
return {
|
||||
modelId: this.form.model.ttsModelId,
|
||||
language: this.selectedLanguage,
|
||||
storedLanguage: this.form.ttsLanguage,
|
||||
voiceId: this.form.ttsVoiceId,
|
||||
languageTouched: this.ttsLanguageTouched,
|
||||
voiceTouched: this.ttsVoiceTouched,
|
||||
voiceOptions: this.cloneTtsDraft(this.voiceOptions) || [],
|
||||
voiceDetails: this.cloneTtsDraft(this.voiceDetails) || {},
|
||||
languageOptions: this.cloneTtsDraft(this.languageOptions) || []
|
||||
};
|
||||
},
|
||||
restoreTtsDraft(draft) {
|
||||
const restored = this.cloneTtsDraft(draft);
|
||||
if (!restored) {
|
||||
return false;
|
||||
}
|
||||
this.form.model.ttsModelId = restored.modelId;
|
||||
this.selectedLanguage = restored.language;
|
||||
this.form.ttsLanguage = restored.storedLanguage;
|
||||
this.form.ttsVoiceId = restored.voiceId;
|
||||
this.ttsLanguageTouched = restored.languageTouched;
|
||||
this.ttsVoiceTouched = restored.voiceTouched;
|
||||
this.voiceOptions = restored.voiceOptions;
|
||||
this.voiceDetails = restored.voiceDetails;
|
||||
this.languageOptions = restored.languageOptions;
|
||||
this.lastValidTtsDraft = restored;
|
||||
return true;
|
||||
},
|
||||
splitVoiceLanguages(voice) {
|
||||
return voice && voice.languages
|
||||
? voice.languages.split(/[、;;,,]/).map(lang => lang.trim()).filter(Boolean)
|
||||
: [];
|
||||
},
|
||||
buildTtsDraft(modelId, voices, options = {}) {
|
||||
if (!Array.isArray(voices) || voices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const voiceDetails = voices.reduce((result, voice) => {
|
||||
if (voice && voice.id) {
|
||||
result[voice.id] = voice;
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
const validVoices = Object.values(voiceDetails);
|
||||
if (validVoices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allLanguages = new Set();
|
||||
validVoices.forEach((voice) => {
|
||||
this.splitVoiceLanguages(voice).forEach((language) => allLanguages.add(language));
|
||||
});
|
||||
const languageOptions = Array.from(allLanguages).map((language) => ({
|
||||
value: language,
|
||||
label: language
|
||||
}));
|
||||
const languageExists = (language) => language
|
||||
&& languageOptions.some((option) => option.value === language);
|
||||
const preferredVoiceId = options.preferredVoiceId || this.form.ttsVoiceId;
|
||||
const preferredVoiceLanguage = this.splitVoiceLanguages(voiceDetails[preferredVoiceId])[0] || "";
|
||||
const languageCandidates = [
|
||||
options.preferredLanguage,
|
||||
preferredVoiceLanguage,
|
||||
this.form.ttsLanguage,
|
||||
this.selectedLanguage,
|
||||
languageOptions[0]?.value
|
||||
];
|
||||
const preferredVoiceHasNoLanguage = Boolean(
|
||||
voiceDetails[preferredVoiceId]
|
||||
&& this.splitVoiceLanguages(voiceDetails[preferredVoiceId]).length === 0
|
||||
);
|
||||
let language = options.preferredLanguage === "" && preferredVoiceHasNoLanguage
|
||||
? ""
|
||||
: languageCandidates.find(languageExists) || "";
|
||||
const filterVoices = (targetLanguage) => validVoices.filter((voice) => {
|
||||
const languages = this.splitVoiceLanguages(voice);
|
||||
return languages.length === 0 || languages.includes(targetLanguage);
|
||||
});
|
||||
let filteredVoices = filterVoices(language);
|
||||
if (filteredVoices.length === 0) {
|
||||
const fallbackVoice = validVoices.find((voice) => this.splitVoiceLanguages(voice).length > 0);
|
||||
if (fallbackVoice) {
|
||||
language = this.splitVoiceLanguages(fallbackVoice)[0];
|
||||
filteredVoices = filterVoices(language);
|
||||
} else {
|
||||
filteredVoices = validVoices;
|
||||
}
|
||||
}
|
||||
if (filteredVoices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferredVoice = filteredVoices.find((voice) => voice.id === preferredVoiceId);
|
||||
const voice = preferredVoice || (options.autoSelectVoice ? filteredVoices[0] : null);
|
||||
return {
|
||||
modelId,
|
||||
language,
|
||||
voiceId: voice?.id || preferredVoiceId || "",
|
||||
voiceDetails,
|
||||
languageOptions,
|
||||
voiceOptions: filteredVoices.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
voiceDemo: item.voiceDemo,
|
||||
voice_demo: item.voice_demo,
|
||||
isClone: Boolean(item.isClone),
|
||||
train_status: item.trainStatus
|
||||
}))
|
||||
};
|
||||
},
|
||||
applyTtsDraft(draft, options = {}) {
|
||||
this.form.model.ttsModelId = draft.modelId;
|
||||
this.voiceDetails = draft.voiceDetails;
|
||||
this.languageOptions = draft.languageOptions;
|
||||
this.voiceOptions = draft.voiceOptions;
|
||||
this.selectedLanguage = draft.language;
|
||||
this.form.ttsLanguage = draft.language;
|
||||
this.form.ttsVoiceId = draft.voiceId;
|
||||
if (options.markTouched) {
|
||||
this.ttsLanguageTouched = true;
|
||||
this.ttsVoiceTouched = true;
|
||||
}
|
||||
this.ttsSettings = {
|
||||
volume: this.form.ttsVolume !== null && this.form.ttsVolume !== undefined ? this.form.ttsVolume : 0,
|
||||
speed: this.form.ttsRate !== null && this.form.ttsRate !== undefined ? this.form.ttsRate : 0,
|
||||
pitch: this.form.ttsPitch !== null && this.form.ttsPitch !== undefined ? this.form.ttsPitch : 0
|
||||
};
|
||||
},
|
||||
handleVoiceOptionsFailure(requestSeq, rollbackState) {
|
||||
if (requestSeq !== this.voiceFetchSeq) {
|
||||
return;
|
||||
}
|
||||
this.voiceOptionsLoading = false;
|
||||
if (!this.restoreTtsDraft(rollbackState)) {
|
||||
this.voiceOptions = [];
|
||||
this.voiceDetails = {};
|
||||
this.languageOptions = [];
|
||||
}
|
||||
this.$message.error(i18n.t("ttsModel.fetchVoicesFailed"));
|
||||
},
|
||||
getVoiceDefaultLanguage(voiceId) {
|
||||
if (!voiceId || !this.voiceDetails || !this.voiceDetails[voiceId]?.languages) {
|
||||
return "";
|
||||
@@ -1022,11 +1273,11 @@ export default {
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
const filteredVoices = allVoices.filter(voice => {
|
||||
if (!voice.languages) {
|
||||
// 对于没有语言信息的克隆音色,始终显示
|
||||
return Boolean(voice.isClone);
|
||||
const languagesArray = this.splitVoiceLanguages(voice);
|
||||
if (languagesArray.length === 0) {
|
||||
// 未声明语言的合法音色由 provider 自行解释,不在前端强制过滤。
|
||||
return true;
|
||||
}
|
||||
const languagesArray = voice.languages.split(/[、;;,,]/).map(lang => lang.trim()).filter(lang => lang);
|
||||
return languagesArray.includes(this.selectedLanguage);
|
||||
});
|
||||
|
||||
@@ -1059,6 +1310,9 @@ export default {
|
||||
this.ttsLanguageTouched = true;
|
||||
this.form.ttsLanguage = this.selectedLanguage;
|
||||
this.filterVoicesByLanguage({ autoSelectVoice: true });
|
||||
if (this.form.ttsVoiceId) {
|
||||
this.lastValidTtsDraft = this.captureTtsDraft();
|
||||
}
|
||||
},
|
||||
handleVoiceChange() {
|
||||
this.ttsVoiceTouched = true;
|
||||
@@ -1066,6 +1320,9 @@ export default {
|
||||
this.form.ttsLanguage = this.selectedLanguage;
|
||||
this.ttsLanguageTouched = true;
|
||||
}
|
||||
if (this.form.ttsVoiceId) {
|
||||
this.lastValidTtsDraft = this.captureTtsDraft();
|
||||
}
|
||||
},
|
||||
shouldSubmitTtsLanguage() {
|
||||
return this.ttsLanguageTouched;
|
||||
@@ -1089,7 +1346,11 @@ export default {
|
||||
},
|
||||
handleModelChange(type, value) {
|
||||
if (type === "Intent" && value !== "Intent_nointent") {
|
||||
this.fetchAllFunctions();
|
||||
this.fetchAllFunctions().then((metadataReady) => {
|
||||
if (metadataReady) {
|
||||
this.enrichCurrentFunctionsWithMetadata();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (type === "Memory") {
|
||||
if (value === "Memory_nomem") {
|
||||
@@ -1112,44 +1373,93 @@ export default {
|
||||
this.updateIntentOptionsVisibility();
|
||||
}
|
||||
if (type === "TTS") {
|
||||
const preferredLanguage = this.selectedLanguage;
|
||||
this.ttsLanguageTouched = true;
|
||||
this.ttsVoiceTouched = true;
|
||||
this.form.ttsVoiceId = "";
|
||||
this.form.ttsLanguage = "";
|
||||
const rollbackState = this.cloneTtsDraft(this.lastValidTtsDraft);
|
||||
this.fetchVoiceOptions(value, {
|
||||
autoSelectVoice: true,
|
||||
preferredLanguage
|
||||
preferredLanguage: rollbackState?.language || this.selectedLanguage,
|
||||
rollbackState,
|
||||
markTouched: true
|
||||
});
|
||||
}
|
||||
},
|
||||
fetchAllFunctions() {
|
||||
return new Promise((resolve, reject) => {
|
||||
Api.model.getPluginFunctionList(null, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.allFunctions = data.data.map((item) => {
|
||||
const meta = JSON.parse(item.fields || "[]");
|
||||
const params = meta.reduce((m, f) => {
|
||||
m[f.key] = f.default;
|
||||
return m;
|
||||
}, {});
|
||||
return { ...item, fieldsMeta: meta, params };
|
||||
});
|
||||
resolve();
|
||||
} else {
|
||||
this.$message.error(data.msg || i18n.t("roleConfig.fetchPluginsFailed"));
|
||||
reject();
|
||||
parsePluginFields(fields) {
|
||||
if (Array.isArray(fields)) {
|
||||
return fields;
|
||||
}
|
||||
if (typeof fields !== "string" || !fields.trim()) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fields);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
fetchAllFunctions(options = {}) {
|
||||
if (this.pluginMetadataReady) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (this.pluginMetadataLoading) {
|
||||
return this.pluginMetadataLoading;
|
||||
}
|
||||
|
||||
this.pluginMetadataLoading = new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (ready, error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
settled = true;
|
||||
this.pluginMetadataReady = ready;
|
||||
if (!ready && options.showError !== false) {
|
||||
this.$message.error(error?.data?.msg || error?.msg || i18n.t("roleConfig.fetchPluginsFailed"));
|
||||
}
|
||||
resolve(ready);
|
||||
};
|
||||
|
||||
Api.model.getPluginFunctionList(null, ({ data }) => {
|
||||
if (data?.code !== 0) {
|
||||
finish(false, data);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.allFunctions = (data.data || []).map((item) => {
|
||||
const fieldsMeta = this.parsePluginFields(item.fields);
|
||||
const params = fieldsMeta.reduce((result, field) => {
|
||||
if (field?.key) {
|
||||
result[field.key] = field.default;
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
return { ...item, fieldsMeta, params };
|
||||
});
|
||||
finish(true);
|
||||
} catch (error) {
|
||||
finish(false, error);
|
||||
}
|
||||
}, (error) => finish(false, error));
|
||||
}).finally(() => {
|
||||
this.pluginMetadataLoading = null;
|
||||
});
|
||||
|
||||
return this.pluginMetadataLoading;
|
||||
},
|
||||
openFunctionDialog() {
|
||||
// 显示编辑对话框时,确保 allFunctions 已经加载
|
||||
if (this.allFunctions.length === 0) {
|
||||
this.fetchAllFunctions().then(() => (this.showFunctionDialog = true));
|
||||
} else {
|
||||
this.showFunctionDialog = true;
|
||||
if (this.agentReloading || !this.agentFunctionsLoaded) {
|
||||
return;
|
||||
}
|
||||
if (this.pluginMetadataReady) {
|
||||
this.enrichCurrentFunctionsWithMetadata();
|
||||
this.showFunctionDialog = true;
|
||||
return;
|
||||
}
|
||||
this.fetchAllFunctions().then((metadataReady) => {
|
||||
if (metadataReady) {
|
||||
this.enrichCurrentFunctionsWithMetadata();
|
||||
this.showFunctionDialog = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
openContextProviderDialog() {
|
||||
this.showContextProviderDialog = true;
|
||||
@@ -1520,12 +1830,46 @@ export default {
|
||||
this.inputVisible = false;
|
||||
this.inputValue = '';
|
||||
},
|
||||
getAgentTags(agentId) {
|
||||
Api.agent.getAgentTags(agentId, ({ data }) => {
|
||||
if (data.code === 0) {
|
||||
this.dynamicTags = data.data || [];
|
||||
this.originalTagNames = this.dynamicTags.map(tag => tag.tagName);
|
||||
}
|
||||
getAgentTags(agentId, options = {}) {
|
||||
const requestSeq = ++this.agentTagsFetchSeq;
|
||||
this.agentTagsLoaded = false;
|
||||
if (!agentId) {
|
||||
this.dynamicTags = [];
|
||||
this.originalTagNames = [];
|
||||
this.agentTagsLoaded = true;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const handleFailure = (error) => {
|
||||
if (requestSeq !== this.agentTagsFetchSeq) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
this.agentTagsLoaded = false;
|
||||
if (options.showError !== false) {
|
||||
this.$message.error(error?.data?.msg || i18n.t("roleConfig.fetchConfigFailed"));
|
||||
}
|
||||
resolve(false);
|
||||
};
|
||||
Api.agent.getAgentTags(agentId, ({ data }) => {
|
||||
if (requestSeq !== this.agentTagsFetchSeq) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (data?.code === 0) {
|
||||
try {
|
||||
this.dynamicTags = Array.isArray(data.data) ? data.data : [];
|
||||
this.originalTagNames = this.dynamicTags.map(tag => tag.tagName);
|
||||
this.agentTagsLoaded = true;
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
handleFailure(error);
|
||||
}
|
||||
} else {
|
||||
handleFailure(data);
|
||||
}
|
||||
}, handleFailure);
|
||||
});
|
||||
},
|
||||
isSameStringList(left, right) {
|
||||
@@ -1547,13 +1891,18 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.agentReloadSeq += 1;
|
||||
this.agentConfigFetchSeq += 1;
|
||||
this.agentTagsFetchSeq += 1;
|
||||
this.currentVersionFetchSeq += 1;
|
||||
this.voiceFetchSeq += 1;
|
||||
},
|
||||
async mounted() {
|
||||
this.lastValidTtsDraft = this.captureTtsDraft();
|
||||
const agentId = this.$route.query.agentId;
|
||||
if (agentId) {
|
||||
this.fetchAgentConfig(agentId);
|
||||
this.getAgentTags(agentId);
|
||||
this.fetchAllFunctions();
|
||||
this.fetchCurrentVersion(agentId);
|
||||
this.reloadAgentPage(agentId);
|
||||
}
|
||||
this.fetchModelOptions();
|
||||
this.fetchTemplates();
|
||||
|
||||
Reference in New Issue
Block a user