feat: add system test validation and router admin controls
This commit is contained in:
@@ -97,6 +97,8 @@ export function createAdminApp(services) {
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
@@ -315,6 +317,71 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const accounts = await systemTestAccountService.listAccounts();
|
||||
res.json({ accounts });
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/accounts', requireAdmin, async (req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const result = await systemTestAccountService.createAccount({
|
||||
label: req.body?.label,
|
||||
username: req.body?.username,
|
||||
password: req.body?.password,
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return res.status(400).json({ message: result.message ?? '保存测试账号失败' });
|
||||
}
|
||||
res.status(201).json({ account: result.account });
|
||||
});
|
||||
|
||||
adminApi.delete('/system-tests/accounts/:accountId', requireAdmin, async (req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const result = await systemTestAccountService.deleteAccount(req.params.accountId);
|
||||
if (!result.ok) {
|
||||
return res.status(404).json({ message: result.message ?? '测试账号不存在' });
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => {
|
||||
if (!adminSystemTestService?.runSkillValidation) {
|
||||
return res.status(503).json({ message: '系统测试服务未启用' });
|
||||
}
|
||||
const accountId = String(req.body?.accountId ?? '').trim();
|
||||
let username = String(req.body?.username ?? '').trim();
|
||||
let password = String(req.body?.password ?? '');
|
||||
const skillName = String(req.body?.skillName ?? '').trim();
|
||||
if (accountId) {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const account = await systemTestAccountService.getAccountSecret(accountId);
|
||||
if (!account) {
|
||||
return res.status(404).json({ message: '所选测试账号不存在,请刷新后重试' });
|
||||
}
|
||||
username = account.username;
|
||||
password = account.password;
|
||||
}
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: '请输入账号和密码' });
|
||||
}
|
||||
const result = await adminSystemTestService.runSkillValidation({
|
||||
username,
|
||||
password,
|
||||
skillName,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
res.json(await wechatAdmin.listBindings(req.query));
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import { createDbPool, isDatabaseConfigured } from './db.mjs';
|
||||
import { projectRoot } from './load-env.mjs';
|
||||
import { importMemind, resolveMemindLib } from './lib-path.mjs';
|
||||
import { createSystemTestAccountService, ensureSystemTestAccountSchema } from './system-test-accounts.mjs';
|
||||
|
||||
export async function bootstrapAdminServices() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
@@ -28,6 +29,7 @@ export async function bootstrapAdminServices() {
|
||||
updateMindSpaceConfig,
|
||||
} = await importMemind('mindspace-config.mjs');
|
||||
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
||||
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
|
||||
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
||||
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
||||
const {
|
||||
@@ -86,6 +88,11 @@ export async function bootstrapAdminServices() {
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
const adminSystemTestService = createAdminSystemTestService({
|
||||
pool,
|
||||
userAuth,
|
||||
portalBaseUrl: `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
|
||||
});
|
||||
const wechatMpConfig = loadWechatMpConfig();
|
||||
const wechatMpService = createWechatMpService({
|
||||
config: wechatMpConfig,
|
||||
@@ -111,6 +118,8 @@ export async function bootstrapAdminServices() {
|
||||
const subscriptionService = createSubscriptionService(pool, {
|
||||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||||
});
|
||||
await ensureSystemTestAccountSchema(pool);
|
||||
const systemTestAccountService = createSystemTestAccountService(pool);
|
||||
|
||||
console.log(`Admin DB connected (${process.env.MYSQL_DATABASE ?? 'via DATABASE_URL'})`);
|
||||
console.log(`Users root: ${usersRoot}`);
|
||||
@@ -127,6 +136,8 @@ export async function bootstrapAdminServices() {
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
|
||||
@@ -104,6 +104,8 @@ ready
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
@@ -123,6 +125,8 @@ ready
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const TABLE = 'h5_admin_system_test_accounts';
|
||||
|
||||
function resolveEncryptionKey(explicitKey) {
|
||||
const raw =
|
||||
explicitKey ??
|
||||
process.env.H5_SETTINGS_ENCRYPTION_KEY ??
|
||||
process.env.TKMIND_SERVER__SECRET_KEY ??
|
||||
'local-dev-secret';
|
||||
return crypto.createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
function encryptSecret(plaintext, encryptionKey) {
|
||||
const key = resolveEncryptionKey(encryptionKey);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decryptSecret({ ciphertext, iv, tag }, encryptionKey) {
|
||||
const key = resolveEncryptionKey(encryptionKey);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
const plain = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plain.toString('utf8');
|
||||
}
|
||||
|
||||
function maskApiKey(apiKey) {
|
||||
if (!apiKey) return '';
|
||||
if (apiKey.length <= 8) return '*'.repeat(apiKey.length);
|
||||
const head = apiKey.slice(0, 4);
|
||||
const tail = apiKey.slice(-4);
|
||||
return `${head}${'*'.repeat(Math.max(apiKey.length - 8, 4))}${tail}`;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function toAccountRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
username: row.username,
|
||||
passwordMasked: maskApiKey(row.password_preview ?? ''),
|
||||
createdAt: Number(row.created_at ?? 0) || 0,
|
||||
updatedAt: Number(row.updated_at ?? 0) || 0,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureSystemTestAccountSchema(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
username VARCHAR(120) NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
password_iv VARCHAR(255) NOT NULL,
|
||||
password_tag VARCHAR(255) NOT NULL,
|
||||
password_preview VARCHAR(255) NOT NULL DEFAULT '',
|
||||
updated_by CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uniq_${TABLE}_username (username)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function createSystemTestAccountService(pool) {
|
||||
if (!pool) {
|
||||
throw new Error('system test account service requires pool');
|
||||
}
|
||||
|
||||
async function listAccounts() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
||||
FROM ${TABLE}
|
||||
ORDER BY updated_at DESC, created_at DESC`,
|
||||
);
|
||||
return rows.map(toAccountRow);
|
||||
}
|
||||
|
||||
async function getAccountSecret(accountId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_ciphertext, password_iv, password_tag
|
||||
FROM ${TABLE}
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[accountId],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
username: row.username,
|
||||
password: decryptSecret({
|
||||
ciphertext: row.password_ciphertext,
|
||||
iv: row.password_iv,
|
||||
tag: row.password_tag,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function createAccount({ label, username, password, updatedBy }) {
|
||||
const normalizedUsername = normalizeText(username);
|
||||
const normalizedLabel = normalizeText(label) || normalizedUsername;
|
||||
if (!normalizedUsername || !password) {
|
||||
return { ok: false, message: '账号和密码不能为空' };
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const encrypted = encryptSecret(String(password));
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO ${TABLE}
|
||||
(id, label, username, password_ciphertext, password_iv, password_tag, password_preview, updated_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
normalizedLabel,
|
||||
normalizedUsername,
|
||||
encrypted.ciphertext,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
maskApiKey(String(password)),
|
||||
updatedBy ?? null,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ER_DUP_ENTRY') {
|
||||
return { ok: false, message: '该测试账号已存在,请直接使用或先删除旧账号' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const account = await getAccountById(id);
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
async function getAccountById(accountId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
||||
FROM ${TABLE}
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[accountId],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
return row ? toAccountRow(row) : null;
|
||||
}
|
||||
|
||||
async function deleteAccount(accountId) {
|
||||
const existing = await getAccountById(accountId);
|
||||
if (!existing) return { ok: false, message: '测试账号不存在' };
|
||||
await pool.query(`DELETE FROM ${TABLE} WHERE id = ? LIMIT 1`, [accountId]);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
listAccounts,
|
||||
getAccountSecret,
|
||||
createAccount,
|
||||
deleteAccount,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { MindSpacePage } from './admin/pages/MindSpacePage';
|
||||
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
|
||||
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
||||
import { SkillsPage } from './admin/pages/SkillsPage';
|
||||
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
|
||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
||||
import { UsersPage } from './admin/pages/UsersPage';
|
||||
import { WechatPage } from './admin/pages/WechatPage';
|
||||
@@ -114,6 +115,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
||||
<Route path="billing/*" element={<BillingPage />} />
|
||||
<Route path="capabilities" element={<CapabilitiesPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="system-tests" element={<SystemTestsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="mindspace" element={<MindSpacePage />} />
|
||||
<Route path="memory-v2" element={<MemoryV2Page />} />
|
||||
@@ -158,6 +160,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
||||
|| pathname.startsWith('/billing')
|
||||
|| pathname.startsWith('/capabilities')
|
||||
|| pathname.startsWith('/skills')
|
||||
|| pathname.startsWith('/system-tests')
|
||||
|| pathname.startsWith('/policies')
|
||||
|| pathname.startsWith('/mindspace')
|
||||
|| pathname.startsWith('/memory-v2')
|
||||
|
||||
@@ -29,6 +29,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
{ to: '/wechat', label: '服务号' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置' },
|
||||
{ to: '/memory-v2', label: 'Memory V2' },
|
||||
{ to: '/system-tests', label: '系统测试验证' },
|
||||
{ to: '/capabilities', label: '能力' },
|
||||
{ to: '/skills', label: '技能' },
|
||||
{ to: '/policies', label: '策略' },
|
||||
|
||||
@@ -10,7 +10,8 @@ const QUICK_LINKS = [
|
||||
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
|
||||
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
|
||||
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
|
||||
{ to: '/memory-v2', label: 'Memory V2', desc: '统一配置长期记忆 backend 与开关' },
|
||||
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
|
||||
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
|
||||
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -139,6 +139,17 @@ function defaultConfig(): MemoryV2AdminConfig {
|
||||
vectorEnabled: false,
|
||||
failOpen: true,
|
||||
},
|
||||
chatIntentRouter: {
|
||||
enabled: false,
|
||||
modelProviderKeyId: '',
|
||||
model: '',
|
||||
modelApiType: 'chat',
|
||||
minConfidence: '0.65',
|
||||
memoryResolveEnabled: true,
|
||||
memoryResolveLimit: '8',
|
||||
timeoutMs: '1500',
|
||||
fallbackRoute: 'agent_orchestration',
|
||||
},
|
||||
pgvector: {},
|
||||
qdrant: {},
|
||||
weaviate: {},
|
||||
@@ -154,6 +165,7 @@ function normalizeConfig(config?: Partial<MemoryV2AdminConfig> | null): MemoryV2
|
||||
const base = defaultConfig();
|
||||
return {
|
||||
global: { ...base.global, ...(config?.global ?? {}) },
|
||||
chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) },
|
||||
pgvector: { ...base.pgvector, ...(config?.pgvector ?? {}) },
|
||||
qdrant: { ...base.qdrant, ...(config?.qdrant ?? {}) },
|
||||
weaviate: { ...base.weaviate, ...(config?.weaviate ?? {}) },
|
||||
@@ -216,7 +228,7 @@ export function MemoryV2Page() {
|
||||
const [globalModelSettings, setGlobalModelSettings] = useState<LlmGlobalSettings | null>(null);
|
||||
const [supportedApiTypes, setSupportedApiTypes] = useState<MemoryV2ModelApiType[]>(['chat', 'response']);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyScope, setBusyScope] = useState<'global' | BackendKey | 'reload' | null>(null);
|
||||
const [busyScope, setBusyScope] = useState<'global' | 'chatIntentRouter' | BackendKey | 'reload' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -261,6 +273,20 @@ export function MemoryV2Page() {
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRouterFlag = (key: string) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.checked },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRouterValue = (key: string) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
chatIntentRouter: { ...current.chatIntentRouter, [key]: event.target.value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateBackendFlag = (backend: BackendKey) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@@ -298,6 +324,27 @@ export function MemoryV2Page() {
|
||||
});
|
||||
};
|
||||
|
||||
const updateRouterModelProvider = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const nextProviderKeyId = event.target.value;
|
||||
const nextProviderKey = findProviderKey(providerKeys, nextProviderKeyId);
|
||||
const nextOptions = modelOptionsForProvider(nextProviderKey, globalModelSettings);
|
||||
setDraft((current) => {
|
||||
const currentSection = current.chatIntentRouter;
|
||||
const currentModel = String(currentSection.model ?? '').trim();
|
||||
const nextModel = nextOptions.includes(currentModel)
|
||||
? currentModel
|
||||
: String(nextProviderKey?.defaultModel ?? globalModelSettings?.globalModel ?? '').trim();
|
||||
return {
|
||||
...current,
|
||||
chatIntentRouter: {
|
||||
...currentSection,
|
||||
modelProviderKeyId: nextProviderKeyId,
|
||||
model: nextModel,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const applySavedConfig = (result: MemoryV2AdminConfigResponse, successMessage: string) => {
|
||||
setPayload(result);
|
||||
setDraft(normalizeConfig(result.config));
|
||||
@@ -306,7 +353,7 @@ export function MemoryV2Page() {
|
||||
|
||||
const saveConfigSection = async (
|
||||
patch: Partial<MemoryV2AdminConfig>,
|
||||
scope: 'global' | BackendKey,
|
||||
scope: 'global' | 'chatIntentRouter' | BackendKey,
|
||||
successMessage: string,
|
||||
) => {
|
||||
setBusyScope(scope);
|
||||
@@ -330,6 +377,14 @@ export function MemoryV2Page() {
|
||||
);
|
||||
};
|
||||
|
||||
const saveRouterConfig = async () => {
|
||||
await saveConfigSection(
|
||||
{ chatIntentRouter: draft.chatIntentRouter },
|
||||
'chatIntentRouter',
|
||||
'前置 LLM 意图路由配置已保存。关闭时主站会退回原有 Direct Chat / Agent 判定链路。',
|
||||
);
|
||||
};
|
||||
|
||||
const saveBackendConfig = async (backend: BackendKey) => {
|
||||
await saveConfigSection(
|
||||
{ [backend]: draft[backend] },
|
||||
@@ -339,6 +394,10 @@ export function MemoryV2Page() {
|
||||
};
|
||||
|
||||
const current = payload?.config ? normalizeConfig(payload.config) : draft;
|
||||
const routerSection = draft.chatIntentRouter;
|
||||
const currentRouterSection = current.chatIntentRouter;
|
||||
const routerProviderKey = findProviderKey(providerKeys, String(routerSection.modelProviderKeyId ?? ''));
|
||||
const routerAvailableModels = modelOptionsForProvider(routerProviderKey, globalModelSettings);
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
@@ -401,6 +460,131 @@ export function MemoryV2Page() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>前置 LLM 意图路由</h2>
|
||||
<p className="muted">关闭时主站完全退回原有 Direct Chat / Agent 判定;打开后才会调用轻量 LLM,并可读取 Memory V2 的轻量上下文。</p>
|
||||
</div>
|
||||
<div className="admin-actions">
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(routerSection.enabled)}
|
||||
onChange={updateRouterFlag('enabled')}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
disabled={loading || busyScope !== null}
|
||||
onClick={() => void saveRouterConfig()}
|
||||
>
|
||||
{busyScope === 'chatIntentRouter' ? '保存中...' : '保存路由'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-form-grid">
|
||||
<label>
|
||||
<span>模型来源</span>
|
||||
<select
|
||||
value={String(routerSection.modelProviderKeyId ?? '')}
|
||||
onChange={updateRouterModelProvider}
|
||||
>
|
||||
<option value="">跟随统一模型中心默认</option>
|
||||
{providerKeys.map((item) => (
|
||||
<option key={item.id} value={item.id}>{providerLabel(item)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型</span>
|
||||
<select
|
||||
value={String(routerSection.model ?? '')}
|
||||
onChange={updateRouterValue('model')}
|
||||
>
|
||||
<option value="">
|
||||
{routerProviderKey ? '使用当前 Provider 默认模型' : '跟随统一模型中心默认模型'}
|
||||
</option>
|
||||
{routerAvailableModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>模型类型</span>
|
||||
<select
|
||||
value={String(routerSection.modelApiType ?? 'chat')}
|
||||
onChange={updateRouterValue('modelApiType')}
|
||||
>
|
||||
{MODEL_API_TYPES.map((apiType) => (
|
||||
<option key={apiType.value} value={apiType.value}>{apiType.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>最低置信度</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={String(routerSection.minConfidence ?? '0.65')}
|
||||
onChange={updateRouterValue('minConfidence')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>记忆条数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={String(routerSection.memoryResolveLimit ?? '8')}
|
||||
onChange={updateRouterValue('memoryResolveLimit')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>超时 Ms</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="30000"
|
||||
value={String(routerSection.timeoutMs ?? '1500')}
|
||||
onChange={updateRouterValue('timeoutMs')}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>低置信 / 失败回退</span>
|
||||
<select
|
||||
value={String(routerSection.fallbackRoute ?? 'agent_orchestration')}
|
||||
onChange={updateRouterValue('fallbackRoute')}
|
||||
>
|
||||
<option value="agent_orchestration">Agent 编排</option>
|
||||
<option value="direct_chat">Direct Chat</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(routerSection.memoryResolveEnabled)}
|
||||
onChange={updateRouterFlag('memoryResolveEnabled')}
|
||||
/>
|
||||
路由前读取 Memory V2
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted">
|
||||
当前数据库配置:<strong>{currentRouterSection.enabled ? '已启用' : '未启用'}</strong>
|
||||
{' · '}
|
||||
生效模型链路:<strong>{describeEffectiveModelSource(routerSection, providerKeys, globalModelSettings)}</strong>
|
||||
</p>
|
||||
{!providerKeys.length && (
|
||||
<p className="muted">
|
||||
统一模型中心当前没有可用的激活 key;若启用路由,请先配置 Provider key 与模型列表。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{BACKENDS.map((backend) => {
|
||||
const section = draft[backend.key];
|
||||
const currentSection = current[backend.key];
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createSystemTestAccount,
|
||||
deleteSystemTestAccount,
|
||||
listSkillCatalog,
|
||||
listSystemTestAccounts,
|
||||
runSystemSkillValidation,
|
||||
} from '../../api/client';
|
||||
import type { AdminSystemTestAccount, AdminSystemTestReport, SkillDefinition } from '../../types';
|
||||
import { formatTime } from '../utils/format';
|
||||
|
||||
const DEFAULT_SKILL = 'service-integration-smoke';
|
||||
|
||||
function statusLabel(status: 'passed' | 'warning' | 'failed') {
|
||||
if (status === 'passed') return '通过';
|
||||
if (status === 'warning') return '待处理';
|
||||
return '失败';
|
||||
}
|
||||
|
||||
function AddAccountModal({
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (payload: { label: string; username: string; password: string }) => Promise<void>;
|
||||
}) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const normalizedUsername = username.trim();
|
||||
const normalizedLabel = label.trim() || normalizedUsername;
|
||||
if (!normalizedUsername || !password) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
label: normalizedLabel,
|
||||
username: normalizedUsername,
|
||||
password,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存测试账号失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<div className="modal-box" style={{ maxWidth: 520 }}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3>添加测试账号</h3>
|
||||
<p className="muted">保存到后台配置,后续管理员都可以直接复用。</p>
|
||||
</div>
|
||||
<button type="button" className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
<form className="admin-form plan-form" onSubmit={handleSubmit}>
|
||||
<label className="plan-form-row">
|
||||
<span>显示名称</span>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder="例如 John 测试号"
|
||||
/>
|
||||
</label>
|
||||
<label className="plan-form-row">
|
||||
<span>账号</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="例如 john"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="plan-form-row">
|
||||
<span>密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入该账号密码"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="plan-form-actions">
|
||||
<button type="button" className="admin-btn-secondary" onClick={onClose} disabled={saving}>取消</button>
|
||||
<button type="submit" className="send-btn" disabled={saving || !username.trim() || !password}>
|
||||
{saving ? '保存中…' : '保存账号'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemTestsPage() {
|
||||
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
|
||||
const [skillName, setSkillName] = useState(DEFAULT_SKILL);
|
||||
const [accounts, setAccounts] = useState<AdminSystemTestAccount[]>([]);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [accountsLoading, setAccountsLoading] = useState(true);
|
||||
const [accountBusyId, setAccountBusyId] = useState<string | null>(null);
|
||||
const [showAddAccount, setShowAddAccount] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [report, setReport] = useState<AdminSystemTestReport | null>(null);
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextCatalog = await listSkillCatalog();
|
||||
setCatalog(nextCatalog);
|
||||
if (nextCatalog.some((item) => item.name === DEFAULT_SKILL)) {
|
||||
setSkillName(DEFAULT_SKILL);
|
||||
} else if (nextCatalog[0]?.name) {
|
||||
setSkillName(nextCatalog[0].name);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 skill 列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const nextAccounts = await listSystemTestAccounts();
|
||||
setAccounts(nextAccounts);
|
||||
setSelectedAccountId((current) =>
|
||||
nextAccounts.some((item) => item.id === current) ? current : (nextAccounts[0]?.id ?? ''),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载测试账号失败');
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
const selectedSkill = useMemo(
|
||||
() => catalog.find((item) => item.name === skillName) ?? null,
|
||||
[catalog, skillName],
|
||||
);
|
||||
|
||||
const selectedAccount = useMemo(
|
||||
() => accounts.find((item) => item.id === selectedAccountId) ?? null,
|
||||
[accounts, selectedAccountId],
|
||||
);
|
||||
|
||||
const handleSaveAccount = async (payload: { label: string; username: string; password: string }) => {
|
||||
const account = await createSystemTestAccount(payload);
|
||||
setAccounts((current) => [account, ...current]);
|
||||
setSelectedAccountId(account.id);
|
||||
setShowAddAccount(false);
|
||||
};
|
||||
|
||||
const handleRemoveAccount = async () => {
|
||||
if (!selectedAccount) return;
|
||||
setAccountBusyId(selectedAccount.id);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteSystemTestAccount(selectedAccount.id);
|
||||
const nextAccounts = accounts.filter((item) => item.id !== selectedAccount.id);
|
||||
setAccounts(nextAccounts);
|
||||
setSelectedAccountId(nextAccounts[0]?.id ?? '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除测试账号失败');
|
||||
} finally {
|
||||
setAccountBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedAccount) {
|
||||
setError('请先选择一个测试账号,或先添加账号');
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextReport = await runSystemSkillValidation({
|
||||
accountId: selectedAccount.id,
|
||||
username: '',
|
||||
password: '',
|
||||
skillName,
|
||||
});
|
||||
setReport(nextReport);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '系统测试执行失败');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>系统测试验证</h2>
|
||||
<p className="muted">选择测试账号后,按选定 skill 执行整条服务联调,并汇总异常反馈。</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>执行参数</h2>
|
||||
<p className="muted">默认推荐使用 service-integration-smoke,对真实账号跑登录、直连聊天、记忆读取和 skill 输出验证。</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载 skill 列表中…</p>
|
||||
) : (
|
||||
<form className="admin-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span>验证 Skill</span>
|
||||
<select value={skillName} onChange={(e) => setSkillName(e.target.value)} className="admin-select">
|
||||
{catalog.map((item) => (
|
||||
<option key={item.name} value={item.name}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="form-span-all system-test-account-panel">
|
||||
<label className="system-test-account-select">
|
||||
<span>测试账号</span>
|
||||
<select
|
||||
value={selectedAccountId}
|
||||
onChange={(event) => setSelectedAccountId(event.target.value)}
|
||||
className="admin-select"
|
||||
disabled={accounts.length === 0 || accountsLoading}
|
||||
>
|
||||
{accountsLoading ? (
|
||||
<option value="">加载测试账号中…</option>
|
||||
) : accounts.length === 0 ? (
|
||||
<option value="">请先添加测试账号</option>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.label} ({account.username})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="system-test-account-actions">
|
||||
<button type="button" className="admin-btn-secondary" onClick={() => setShowAddAccount(true)}>
|
||||
+ 添加账号
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-secondary"
|
||||
onClick={() => void handleRemoveAccount()}
|
||||
disabled={!selectedAccount || accountBusyId === selectedAccount?.id}
|
||||
>
|
||||
{accountBusyId === selectedAccount?.id ? '删除中…' : '删除当前账号'}
|
||||
</button>
|
||||
<button type="button" className="admin-btn-secondary" onClick={() => void loadAccounts()} disabled={accountsLoading}>
|
||||
{accountsLoading ? '刷新中…' : '刷新账号'}
|
||||
</button>
|
||||
</div>
|
||||
{selectedAccount ? (
|
||||
<div className="system-test-account-summary muted">
|
||||
当前使用: <strong>{selectedAccount.label}</strong> · @{selectedAccount.username} · 密码 {selectedAccount.passwordMasked}
|
||||
</div>
|
||||
) : (
|
||||
<div className="system-test-account-summary muted">
|
||||
还没有可用测试账号,请先添加账号和密码。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedSkill && (
|
||||
<p className="muted">
|
||||
当前 skill: <strong>{selectedSkill.label}</strong> · {selectedSkill.description}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="send-btn" disabled={running || loading}>
|
||||
{running ? '验证执行中…' : '开始验证'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showAddAccount && (
|
||||
<AddAccountModal
|
||||
onClose={() => setShowAddAccount(false)}
|
||||
onSave={handleSaveAccount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>验证摘要</h2>
|
||||
<p className="muted">
|
||||
账号 @{report.account.username} · skill {report.selectedSkill} · Portal {report.portalBaseUrl}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-grid">
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">总体结果</div>
|
||||
<div className="admin-stat-value">{report.ok ? '通过' : '有问题'}</div>
|
||||
<div className="muted">
|
||||
开始 {formatTime(report.startedAt)} / 完成 {formatTime(report.finishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">通过</div>
|
||||
<div className="admin-stat-value">{report.summary.passed}</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">待处理</div>
|
||||
<div className="admin-stat-value">{report.summary.warnings}</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">失败</div>
|
||||
<div className="admin-stat-value">{report.summary.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>步骤结果</h2>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>步骤</th>
|
||||
<th>状态</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.steps.map((step) => (
|
||||
<tr key={step.key}>
|
||||
<td>{step.label}</td>
|
||||
<td>{statusLabel(step.status)}</td>
|
||||
<td>
|
||||
<div>{step.message}</div>
|
||||
{step.details && (
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary className="muted">查看详情</summary>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{JSON.stringify(step.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>问题反馈</h2>
|
||||
</div>
|
||||
{report.issues.length === 0 ? (
|
||||
<p className="muted">本轮未发现额外问题。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>级别</th>
|
||||
<th>步骤</th>
|
||||
<th>问题</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.issues.map((issue, index) => (
|
||||
<tr key={`${issue.stepKey}-${index}`}>
|
||||
<td>{issue.severity === 'error' ? '错误' : '提醒'}</td>
|
||||
<td>{issue.stepKey}</td>
|
||||
<td>
|
||||
<div>{issue.title}</div>
|
||||
<div className="muted" style={{ marginTop: 6 }}>{issue.detail}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
AdminDashboardSummary,
|
||||
AdminServiceRestartAction,
|
||||
AdminServiceRestartResult,
|
||||
AdminSystemTestReport,
|
||||
AdminSystemTestAccount,
|
||||
AdminSubscription,
|
||||
AdminUserRow,
|
||||
AuthStatus,
|
||||
@@ -310,6 +312,41 @@ export async function listMemoryV2ModelOptions(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSystemSkillValidation(payload: {
|
||||
accountId?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
skillName: string;
|
||||
}): Promise<AdminSystemTestReport> {
|
||||
return portalFetch('/admin-api/system-tests/skill-validation', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSystemTestAccounts(): Promise<AdminSystemTestAccount[]> {
|
||||
const result = await portalFetch<{ accounts: AdminSystemTestAccount[] }>('/admin-api/system-tests/accounts');
|
||||
return result.accounts ?? [];
|
||||
}
|
||||
|
||||
export async function createSystemTestAccount(payload: {
|
||||
label?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<AdminSystemTestAccount> {
|
||||
const result = await portalFetch<{ account: AdminSystemTestAccount }>('/admin-api/system-tests/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return result.account;
|
||||
}
|
||||
|
||||
export async function deleteSystemTestAccount(accountId: string): Promise<void> {
|
||||
await portalFetch(`/admin-api/system-tests/accounts/${encodeURIComponent(accountId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWechatBindings(params?: {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
|
||||
@@ -1649,6 +1649,30 @@ body,
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.system-test-account-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
.system-test-account-select {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.system-test-account-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.system-test-account-summary {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Plan catalog table extras ──────────────────────────────────── */
|
||||
.plan-desc {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -267,6 +267,7 @@ export type MemoryV2AdminConfig = {
|
||||
vectorEnabled?: boolean;
|
||||
failOpen?: boolean;
|
||||
};
|
||||
chatIntentRouter: MemoryV2AdminSection;
|
||||
pgvector: MemoryV2AdminSection;
|
||||
qdrant: MemoryV2AdminSection;
|
||||
weaviate: MemoryV2AdminSection;
|
||||
@@ -283,6 +284,53 @@ export type MemoryV2AdminConfigResponse = {
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type AdminSystemTestStepStatus = 'passed' | 'warning' | 'failed';
|
||||
|
||||
export type AdminSystemTestStep = {
|
||||
key: string;
|
||||
label: string;
|
||||
status: AdminSystemTestStepStatus;
|
||||
message: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AdminSystemTestIssue = {
|
||||
stepKey: string;
|
||||
severity: 'warning' | 'error';
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type AdminSystemTestReport = {
|
||||
ok: boolean;
|
||||
startedAt: number;
|
||||
finishedAt: number;
|
||||
portalBaseUrl: string;
|
||||
selectedSkill: string;
|
||||
account: {
|
||||
userId?: string;
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
};
|
||||
summary: {
|
||||
passed: number;
|
||||
warnings: number;
|
||||
failed: number;
|
||||
};
|
||||
steps: AdminSystemTestStep[];
|
||||
issues: AdminSystemTestIssue[];
|
||||
};
|
||||
|
||||
export type AdminSystemTestAccount = {
|
||||
id: string;
|
||||
label: string;
|
||||
username: string;
|
||||
passwordMasked: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
||||
export type WechatBinding = {
|
||||
userId: string;
|
||||
username: string;
|
||||
|
||||
Reference in New Issue
Block a user