Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 458f291985 | |||
| 65a6d7254b | |||
| 16b1a90edf | |||
| afc667a9ce | |||
| 10e24be243 | |||
| 2596c8f2c6 | |||
| dd3fed5b14 | |||
| b009f31aae | |||
| 4d7bcbe948 | |||
| 1b5bbdf467 | |||
| 6977f9fc24 | |||
| ce8247743e | |||
| 04859defeb | |||
| bacc31c83a | |||
| 693fc8a712 | |||
| 00a4855715 | |||
| 4f708d10bd | |||
| 65970f383d | |||
| f6624936d6 | |||
| 790c5794e9 |
@@ -1 +0,0 @@
|
|||||||
26747
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
25645
|
|
||||||
@@ -42,5 +42,10 @@ VITE_MAIN_APP_URL=https://h5.tkmind.cn
|
|||||||
MEMIND_UMAMI_SSO_SECRET=replace-with-the-same-random-secret-used-by-umami
|
MEMIND_UMAMI_SSO_SECRET=replace-with-the-same-random-secret-used-by-umami
|
||||||
UMAMI_SSO_USERNAME=admin
|
UMAMI_SSO_USERNAME=admin
|
||||||
|
|
||||||
|
# Rybbit 仅允许从 MemindAdm 单点登录进入(必须与 105 Rybbit 一致)
|
||||||
|
MEMIND_RYBBIT_SSO_SECRET=replace-with-the-same-random-secret-used-by-rybbit
|
||||||
|
RYBBIT_SSO_EMAIL=admin@tkmind.cn
|
||||||
|
RYBBIT_URL=https://rybbit.tkmind.cn
|
||||||
|
|
||||||
# Plaza 帖子预览链接
|
# Plaza 帖子预览链接
|
||||||
# VITE_PLAZA_BASE=https://plaza.tkmind.cn
|
# VITE_PLAZA_BASE=https://plaza.tkmind.cn
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ dist-ssr/
|
|||||||
memind-lib/
|
memind-lib/
|
||||||
.vite/
|
.vite/
|
||||||
*.log
|
*.log
|
||||||
|
*.pid
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"local_restart": "bash scripts/local_restart.sh",
|
"local_restart": "bash scripts/local_restart.sh",
|
||||||
"pro_restart": "bash scripts/pro_restart.sh",
|
"pro_restart": "bash scripts/pro_restart.sh",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test:orchestrator": "node --test server/orchestrator-routes.test.mjs",
|
||||||
"preview": "node scripts/preview.mjs",
|
"preview": "node scripts/preview.mjs",
|
||||||
"dev:preview": "vite preview"
|
"dev:preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
+46
-3
@@ -88,12 +88,24 @@ need_cmd shasum
|
|||||||
need_cmd node
|
need_cmd node
|
||||||
|
|
||||||
if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then
|
if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then
|
||||||
bash "${ROOT}/scripts/check-release-ready.sh"
|
ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh"
|
||||||
else
|
else
|
||||||
echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2
|
echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ROOT_GIT_BRANCH="$(git -C "${ROOT}" branch --show-current)"
|
||||||
|
ROOT_GIT_HEAD="$(git -C "${ROOT}" rev-parse HEAD)"
|
||||||
|
ROOT_ORIGIN_HEAD="$(git -C "${ROOT}" rev-parse origin/main)"
|
||||||
|
[[ "${ROOT_GIT_BRANCH}" == "main" ]] || {
|
||||||
|
echo "memindadm 生产发布必须从 main 执行,当前分支: ${ROOT_GIT_BRANCH:-detached}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "${ROOT_GIT_HEAD}" == "${ROOT_ORIGIN_HEAD}" ]] || {
|
||||||
|
echo "memindadm main 必须与 origin/main 完全一致" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
[[ -f "${IGNORE_FILE}" ]] || {
|
[[ -f "${IGNORE_FILE}" ]] || {
|
||||||
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
|
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -127,8 +139,23 @@ say "验证共享模块图像运行依赖"
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
MEMIND_GIT_HEAD="$(git -C "${MEMIND_SRC}" rev-parse HEAD 2>/dev/null || echo unknown)"
|
git -C "${MEMIND_SRC}" fetch origin main --quiet
|
||||||
MEMIND_GIT_BRANCH="$(git -C "${MEMIND_SRC}" branch --show-current 2>/dev/null || echo detached)"
|
MEMIND_GIT_HEAD="$(git -C "${MEMIND_SRC}" rev-parse HEAD)"
|
||||||
|
MEMIND_ORIGIN_HEAD="$(git -C "${MEMIND_SRC}" rev-parse origin/main)"
|
||||||
|
MEMIND_GIT_BRANCH="$(git -C "${MEMIND_SRC}" branch --show-current)"
|
||||||
|
[[ "${MEMIND_GIT_BRANCH}" == "main" ]] || {
|
||||||
|
echo "Memind 共享模块必须从 main 打包,当前分支: ${MEMIND_GIT_BRANCH:-detached}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "${MEMIND_GIT_HEAD}" == "${MEMIND_ORIGIN_HEAD}" ]] || {
|
||||||
|
echo "Memind main 必须与 origin/main 完全一致" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -z "$(git -C "${MEMIND_SRC}" status --porcelain=v1 --untracked-files=all)" ]] || {
|
||||||
|
echo "Memind 共享模块工作区必须干净" >&2
|
||||||
|
git -C "${MEMIND_SRC}" status --short --untracked-files=all >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
say "打包 Memind 共享模块到 memind-lib"
|
say "打包 Memind 共享模块到 memind-lib"
|
||||||
rm -rf "${MEMIND_LIB}"
|
rm -rf "${MEMIND_LIB}"
|
||||||
@@ -169,6 +196,22 @@ grep -q 'tkmind-excel' "${MEMIND_LIB}/capabilities.mjs" || {
|
|||||||
echo "memind-lib 缺少 Excel Analyst runtime 扩展映射" >&2
|
echo "memind-lib 缺少 Excel Analyst runtime 扩展映射" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
[[ -f "${MEMIND_LIB}/llm-providers.mjs" ]] || {
|
||||||
|
echo "memind-lib 缺少 llm-providers.mjs" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
grep -q "'deepseek-v4-pro'" "${MEMIND_LIB}/llm-providers.mjs" || {
|
||||||
|
echo "memind-lib/llm-providers.mjs 缺少 deepseek-v4-pro 模型目录,拒绝发布旧 DeepSeek catalog" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
grep -q "'deepseek-v4-flash'" "${MEMIND_LIB}/llm-providers.mjs" || {
|
||||||
|
echo "memind-lib/llm-providers.mjs 缺少 deepseek-v4-flash 模型目录" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if grep -q "'deepseek-chat'" "${MEMIND_LIB}/llm-providers.mjs" && ! grep -q "LEGACY_DEEPSEEK_CHAT" "${MEMIND_LIB}/llm-providers.mjs"; then
|
||||||
|
echo "memind-lib/llm-providers.mjs 仍含 legacy deepseek-chat 且无 LEGACY_DEEPSEEK_CHAT 标记,目录可能未升级" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
|
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
|
||||||
say "发布确认"
|
say "发布确认"
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ export ADM_WEB_HOST="${WEB_HOST}"
|
|||||||
export ADM_PORT="${PORT}"
|
export ADM_PORT="${PORT}"
|
||||||
export VITE_BASE_PATH="${BASE_PATH%/}"
|
export VITE_BASE_PATH="${BASE_PATH%/}"
|
||||||
|
|
||||||
|
wait_for_http() {
|
||||||
|
local url="$1"
|
||||||
|
local attempts="${2:-30}"
|
||||||
|
local attempt
|
||||||
|
for ((attempt = 1; attempt <= attempts; attempt += 1)); do
|
||||||
|
if curl -sf --max-time 2 "${url}" >/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if [[ ! -d dist ]]; then
|
if [[ ! -d dist ]]; then
|
||||||
echo "❌ ${ROOT}/dist 不存在,请先构建或 rsync dist" >&2
|
echo "❌ ${ROOT}/dist 不存在,请先构建或 rsync dist" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -61,9 +74,8 @@ sleep 1
|
|||||||
echo "==> 启动 Admin API @ ${API_HOST}:${API_PORT}..."
|
echo "==> 启动 Admin API @ ${API_HOST}:${API_PORT}..."
|
||||||
nohup node server/index.mjs >>"${API_LOG}" 2>&1 &
|
nohup node server/index.mjs >>"${API_LOG}" 2>&1 &
|
||||||
echo $! >"${API_PID_FILE}"
|
echo $! >"${API_PID_FILE}"
|
||||||
sleep 2
|
|
||||||
|
|
||||||
if ! curl -sf "http://127.0.0.1:${API_PORT}/health" >/dev/null; then
|
if ! wait_for_http "http://127.0.0.1:${API_PORT}/health" 30; then
|
||||||
echo "❌ Admin API 启动失败,最近日志:" >&2
|
echo "❌ Admin API 启动失败,最近日志:" >&2
|
||||||
tail -n 40 "${API_LOG}" >&2 || true
|
tail -n 40 "${API_LOG}" >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
@@ -72,11 +84,10 @@ fi
|
|||||||
echo "==> 启动 vite preview @ ${WEB_HOST}:${PORT} (base=${BASE_PATH})..."
|
echo "==> 启动 vite preview @ ${WEB_HOST}:${PORT} (base=${BASE_PATH})..."
|
||||||
nohup npx vite preview --config scripts/vite-preview.config.mjs >>"${LOG}" 2>&1 &
|
nohup npx vite preview --config scripts/vite-preview.config.mjs >>"${LOG}" 2>&1 &
|
||||||
echo $! >"${PID_FILE}"
|
echo $! >"${PID_FILE}"
|
||||||
sleep 2
|
|
||||||
|
|
||||||
preview_url="http://127.0.0.1:${PORT}${BASE_PATH}"
|
preview_url="http://127.0.0.1:${PORT}${BASE_PATH}"
|
||||||
login_probe="http://127.0.0.1:${PORT}/auth/status"
|
login_probe="http://127.0.0.1:${PORT}/auth/status"
|
||||||
if curl -sf "${preview_url}" >/dev/null && curl -sf "${login_probe}" >/dev/null; then
|
if wait_for_http "${preview_url}" 30 && wait_for_http "${login_probe}" 15; then
|
||||||
echo "✅ memind_adm 已就绪: ${preview_url}"
|
echo "✅ memind_adm 已就绪: ${preview_url}"
|
||||||
curl -sf "${preview_url}" >/dev/null && echo " 首页 OK"
|
curl -sf "${preview_url}" >/dev/null && echo " 首页 OK"
|
||||||
echo " /auth 反代 OK"
|
echo " /auth 反代 OK"
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export function createAdminApp(services) {
|
|||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
mindSearchConfigService,
|
mindSearchConfigService,
|
||||||
|
orchestratorConfigService,
|
||||||
|
orchestratorObservabilityService,
|
||||||
personalMemoryCandidateStore,
|
personalMemoryCandidateStore,
|
||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
@@ -232,6 +234,21 @@ export function createAdminApp(services) {
|
|||||||
res.json({ url: `${baseUrl}/auth/memind?ticket=${encoded}.${signature}` });
|
res.json({ url: `${baseUrl}/auth/memind?ticket=${encoded}.${signature}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminApi.get('/analytics/rybbit-sso', requireAdmin, async (_req, res) => {
|
||||||
|
const sharedSecret = process.env.MEMIND_RYBBIT_SSO_SECRET?.trim();
|
||||||
|
if (!sharedSecret) return res.status(503).json({ message: '未配置 Rybbit 单点登录密钥' });
|
||||||
|
const email = process.env.RYBBIT_SSO_EMAIL?.trim().toLowerCase();
|
||||||
|
if (!email) return res.status(503).json({ message: '未配置 Rybbit 单点登录账号' });
|
||||||
|
const baseUrl = String(process.env.RYBBIT_URL || 'https://rybbit.tkmind.cn').replace(/\/$/, '');
|
||||||
|
const encoded = Buffer.from(JSON.stringify({
|
||||||
|
email,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60,
|
||||||
|
nonce: crypto.randomUUID(),
|
||||||
|
})).toString('base64url');
|
||||||
|
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
|
||||||
|
res.json({ url: `${baseUrl}/api/auth/memind?ticket=${encoded}.${signature}` });
|
||||||
|
});
|
||||||
|
|
||||||
adminApi.get('/users', requireAdmin, async (req, res) => {
|
adminApi.get('/users', requireAdmin, async (req, res) => {
|
||||||
const result = await userAuth.listUsers({
|
const result = await userAuth.listUsers({
|
||||||
page: Number(req.query.page) || 1,
|
page: Number(req.query.page) || 1,
|
||||||
@@ -401,6 +418,80 @@ export function createAdminApp(services) {
|
|||||||
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
|
||||||
res.json(await mindSearchConfigService.getRuntimeState());
|
res.json(await mindSearchConfigService.getRuntimeState());
|
||||||
});
|
});
|
||||||
|
adminApi.post('/mindsearch/services/:serviceId/test', requireAdmin, async (req, res) => {
|
||||||
|
if (!mindSearchConfigService?.testService) return res.status(503).json({ message: 'MindSearch 服务测试未启用' });
|
||||||
|
try {
|
||||||
|
return res.json(await mindSearchConfigService.testService(req.params.serviceId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'SEARCH_SERVICE_NOT_FOUND') return res.status(404).json({ message: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/orchestrator/config', requireAdmin, async (_req, res) => {
|
||||||
|
if (!orchestratorConfigService?.getAdminConfig) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await orchestratorConfigService.getAdminConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOrchestratorConfig = async (req, res) => {
|
||||||
|
if (!orchestratorConfigService?.updateAdminConfig) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await orchestratorConfigService.updateAdminConfig(
|
||||||
|
req.body?.config ?? req.body ?? {},
|
||||||
|
{ updatedBy: req.currentUser.id },
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
adminApi.put('/orchestrator/config', requireAdmin, updateOrchestratorConfig);
|
||||||
|
adminApi.patch('/orchestrator/config', requireAdmin, updateOrchestratorConfig);
|
||||||
|
|
||||||
|
adminApi.get('/orchestrator/runtime', requireAdmin, async (_req, res) => {
|
||||||
|
if (!orchestratorConfigService?.getRuntimeState) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await orchestratorConfigService.getRuntimeState({ probe: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/orchestrator/shadow-runs', requireAdmin, async (req, res) => {
|
||||||
|
if (!orchestratorObservabilityService?.listShadowRuns) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await orchestratorObservabilityService.listShadowRuns({
|
||||||
|
hours: req.query.hours,
|
||||||
|
limit: req.query.limit,
|
||||||
|
status: req.query.status,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/orchestrator/execution-plans', requireAdmin, async (req, res) => {
|
||||||
|
if (!orchestratorObservabilityService?.listExecutionPlans) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator Dry-run 观测服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await orchestratorObservabilityService.listExecutionPlans({
|
||||||
|
hours: req.query.hours,
|
||||||
|
limit: req.query.limit,
|
||||||
|
selection: req.query.selection,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/orchestrator/canary-readiness', requireAdmin, async (_req, res) => {
|
||||||
|
if (!orchestratorObservabilityService?.getCanaryReadiness) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
|
||||||
|
}
|
||||||
|
return res.json(await orchestratorObservabilityService.getCanaryReadiness());
|
||||||
|
});
|
||||||
|
|
||||||
|
adminApi.get('/orchestrator/shadow-runs/:runId', requireAdmin, async (req, res) => {
|
||||||
|
if (!orchestratorObservabilityService?.getShadowRun) {
|
||||||
|
return res.status(503).json({ message: 'Orchestrator 观测服务未启用' });
|
||||||
|
}
|
||||||
|
const result = await orchestratorObservabilityService.getShadowRun(req.params.runId);
|
||||||
|
if (!result) return res.status(404).json({ message: 'Shadow run 不存在' });
|
||||||
|
return res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
|
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
|
||||||
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
|
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export async function bootstrapAdminServices() {
|
|||||||
const createLlmProviderService = await loadCreateLlmProviderService();
|
const createLlmProviderService = await loadCreateLlmProviderService();
|
||||||
const { createWechatAdminService } = await importMemind('wechat-admin.mjs');
|
const { createWechatAdminService } = await importMemind('wechat-admin.mjs');
|
||||||
const { createWechatMpService, loadWechatMpConfig } = await importMemind('wechat-mp.mjs');
|
const { createWechatMpService, loadWechatMpConfig } = await importMemind('wechat-mp.mjs');
|
||||||
|
const { createNotificationDispatcher } = await importMemind('notification-dispatcher.mjs');
|
||||||
const { createPlazaPostService, formatPostRow } = await importMemind('plaza-posts.mjs');
|
const { createPlazaPostService, formatPostRow } = await importMemind('plaza-posts.mjs');
|
||||||
const { createPlazaInteractionService } = await importMemind('plaza-interactions.mjs');
|
const { createPlazaInteractionService } = await importMemind('plaza-interactions.mjs');
|
||||||
const { createPlazaOpsService } = await importMemind('plaza-ops.mjs');
|
const { createPlazaOpsService } = await importMemind('plaza-ops.mjs');
|
||||||
@@ -30,6 +31,12 @@ export async function bootstrapAdminServices() {
|
|||||||
} = await importMemind('mindspace-config.mjs');
|
} = await importMemind('mindspace-config.mjs');
|
||||||
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
||||||
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
|
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
|
||||||
|
const { createOrchestratorAdminConfigService } = await importMemind(
|
||||||
|
'services/orchestrator/admin-config.mjs',
|
||||||
|
);
|
||||||
|
const { createOrchestratorObservabilityService } = await importMemind(
|
||||||
|
'services/orchestrator/observability.mjs',
|
||||||
|
);
|
||||||
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
|
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
|
||||||
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
|
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
|
||||||
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
|
||||||
@@ -98,6 +105,14 @@ export async function bootstrapAdminServices() {
|
|||||||
});
|
});
|
||||||
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
|
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
|
||||||
await mindSearchConfigService.ensureSchema();
|
await mindSearchConfigService.ensureSchema();
|
||||||
|
const orchestratorConfigService = createOrchestratorAdminConfigService(pool, {
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
await orchestratorConfigService.ensureSchema();
|
||||||
|
const orchestratorObservabilityService = createOrchestratorObservabilityService({
|
||||||
|
pool,
|
||||||
|
configService: orchestratorConfigService,
|
||||||
|
});
|
||||||
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
|
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
|
||||||
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
|
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
|
||||||
env: process.env,
|
env: process.env,
|
||||||
@@ -119,6 +134,14 @@ export async function bootstrapAdminServices() {
|
|||||||
throw new Error('admin api does not proxy WeChat chat sessions');
|
throw new Error('admin api does not proxy WeChat chat sessions');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const notificationDispatcher = createNotificationDispatcher({
|
||||||
|
sendWechatTextToUser: wechatMpService?.enabled
|
||||||
|
? (userId, text) => wechatMpService.sendTextToUser(userId, text)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => {
|
||||||
|
await notificationDispatcher.sendRechargeSuccess({ userId, title, body, dedupeKey });
|
||||||
|
});
|
||||||
const wechatAdmin = createWechatAdminService(pool, {
|
const wechatAdmin = createWechatAdminService(pool, {
|
||||||
config: wechatMpConfig,
|
config: wechatMpConfig,
|
||||||
scheduleEnabled: process.env.H5_SCHEDULE_ENABLED === '1',
|
scheduleEnabled: process.env.H5_SCHEDULE_ENABLED === '1',
|
||||||
@@ -156,6 +179,8 @@ export async function bootstrapAdminServices() {
|
|||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
mindSearchConfigService,
|
mindSearchConfigService,
|
||||||
|
orchestratorConfigService,
|
||||||
|
orchestratorObservabilityService,
|
||||||
personalMemoryCandidateStore,
|
personalMemoryCandidateStore,
|
||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ ready
|
|||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
mindSearchConfigService,
|
mindSearchConfigService,
|
||||||
|
orchestratorConfigService,
|
||||||
|
orchestratorObservabilityService,
|
||||||
personalMemoryCandidateStore,
|
personalMemoryCandidateStore,
|
||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
@@ -132,6 +134,8 @@ ready
|
|||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
mindSearchConfigService,
|
mindSearchConfigService,
|
||||||
|
orchestratorConfigService,
|
||||||
|
orchestratorObservabilityService,
|
||||||
personalMemoryCandidateStore,
|
personalMemoryCandidateStore,
|
||||||
skillRuntimeConfigService,
|
skillRuntimeConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createAdminApp } from './app.mjs';
|
||||||
|
|
||||||
|
function createServices({ role = 'admin' } = {}) {
|
||||||
|
const calls = {
|
||||||
|
update: [],
|
||||||
|
shadow: [],
|
||||||
|
plans: [],
|
||||||
|
detail: [],
|
||||||
|
};
|
||||||
|
const configState = {
|
||||||
|
config: {
|
||||||
|
mode: 'shadow',
|
||||||
|
primaryEngine: 'langgraph',
|
||||||
|
fallbackEngine: 'native',
|
||||||
|
serviceUrl: 'http://127.0.0.1:8093',
|
||||||
|
requestTimeoutMs: 5000,
|
||||||
|
rolloutPercent: 0,
|
||||||
|
userAllowlist: [],
|
||||||
|
workflowAllowlist: ['code-run-v1'],
|
||||||
|
fallbackToNative: true,
|
||||||
|
requireHealthy: true,
|
||||||
|
executionEnabled: false,
|
||||||
|
},
|
||||||
|
configVersion: 4,
|
||||||
|
updatedBy: 'admin-user',
|
||||||
|
updatedAt: 1,
|
||||||
|
source: 'admin-db',
|
||||||
|
runtime: {
|
||||||
|
effective: true,
|
||||||
|
shadowsLangGraph: true,
|
||||||
|
executesLangGraph: false,
|
||||||
|
executionHandoff: { enabled: false },
|
||||||
|
},
|
||||||
|
engines: [],
|
||||||
|
executors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
services: {
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
parseCookies: () => ({ test_session: 'token' }),
|
||||||
|
USER_COOKIE: 'test_session',
|
||||||
|
userLoginCookies: () => [],
|
||||||
|
clearUserSessionCookie: () => {},
|
||||||
|
resolveCookieDomainForRequest: () => undefined,
|
||||||
|
userAuth: {
|
||||||
|
getMe: async () => ({ id: 'john-id', username: 'john', role }),
|
||||||
|
},
|
||||||
|
orchestratorConfigService: {
|
||||||
|
getAdminConfig: async () => configState,
|
||||||
|
updateAdminConfig: async (config, context) => {
|
||||||
|
calls.update.push({ config, context });
|
||||||
|
return { ...configState, config, updatedBy: context.updatedBy };
|
||||||
|
},
|
||||||
|
getRuntimeState: async ({ probe }) => ({
|
||||||
|
...configState,
|
||||||
|
serviceHealth: { ok: true, status: 'healthy', probe },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
orchestratorObservabilityService: {
|
||||||
|
listShadowRuns: async (query) => {
|
||||||
|
calls.shadow.push(query);
|
||||||
|
return { metrics: { observations: 0 }, runs: [] };
|
||||||
|
},
|
||||||
|
listExecutionPlans: async (query) => {
|
||||||
|
calls.plans.push(query);
|
||||||
|
return { metrics: { decisions: 0 }, plans: [] };
|
||||||
|
},
|
||||||
|
getCanaryReadiness: async () => ({
|
||||||
|
ready: false,
|
||||||
|
recommendation: 'keep_shadow',
|
||||||
|
}),
|
||||||
|
getShadowRun: async (runId) => {
|
||||||
|
calls.detail.push(runId);
|
||||||
|
return runId === 'missing' ? null : { native: { runId }, remote: { available: true } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startApp(options) {
|
||||||
|
const harness = createServices(options);
|
||||||
|
const server = createAdminApp(harness.services).listen(0, '127.0.0.1');
|
||||||
|
await once(server, 'listening');
|
||||||
|
const address = server.address();
|
||||||
|
assert.ok(address && typeof address === 'object');
|
||||||
|
return {
|
||||||
|
...harness,
|
||||||
|
request: (path, init = {}) => fetch(`http://127.0.0.1:${address.port}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Cookie: 'test_session=token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
close: () => new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => error ? reject(error) : resolve());
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('admin can read orchestrator config, runtime and observability endpoints', async (t) => {
|
||||||
|
const app = await startApp();
|
||||||
|
t.after(app.close);
|
||||||
|
|
||||||
|
const config = await app.request('/admin-api/orchestrator/config');
|
||||||
|
assert.equal(config.status, 200);
|
||||||
|
assert.equal((await config.json()).config.mode, 'shadow');
|
||||||
|
|
||||||
|
const runtime = await app.request('/admin-api/orchestrator/runtime');
|
||||||
|
assert.equal(runtime.status, 200);
|
||||||
|
assert.deepEqual((await runtime.json()).serviceHealth, {
|
||||||
|
ok: true,
|
||||||
|
status: 'healthy',
|
||||||
|
probe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shadow = await app.request(
|
||||||
|
'/admin-api/orchestrator/shadow-runs?hours=24&limit=50&status=failed',
|
||||||
|
);
|
||||||
|
assert.equal(shadow.status, 200);
|
||||||
|
assert.deepEqual(app.calls.shadow, [{ hours: '24', limit: '50', status: 'failed' }]);
|
||||||
|
|
||||||
|
const plans = await app.request(
|
||||||
|
'/admin-api/orchestrator/execution-plans?hours=168&limit=20&selection=native',
|
||||||
|
);
|
||||||
|
assert.equal(plans.status, 200);
|
||||||
|
assert.deepEqual(app.calls.plans, [{ hours: '168', limit: '20', selection: 'native' }]);
|
||||||
|
|
||||||
|
const readiness = await app.request('/admin-api/orchestrator/canary-readiness');
|
||||||
|
assert.equal(readiness.status, 200);
|
||||||
|
assert.equal((await readiness.json()).recommendation, 'keep_shadow');
|
||||||
|
|
||||||
|
const detail = await app.request('/admin-api/orchestrator/shadow-runs/run-1');
|
||||||
|
assert.equal(detail.status, 200);
|
||||||
|
assert.deepEqual(app.calls.detail, ['run-1']);
|
||||||
|
|
||||||
|
const missing = await app.request('/admin-api/orchestrator/shadow-runs/missing');
|
||||||
|
assert.equal(missing.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin config update records the authenticated administrator', async (t) => {
|
||||||
|
const app = await startApp();
|
||||||
|
t.after(app.close);
|
||||||
|
|
||||||
|
const response = await app.request('/admin-api/orchestrator/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ config: { mode: 'off', executionEnabled: false } }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(app.calls.update, [{
|
||||||
|
config: { mode: 'off', executionEnabled: false },
|
||||||
|
context: { updatedBy: 'john-id' },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary users cannot access or mutate orchestrator controls', async (t) => {
|
||||||
|
const app = await startApp({ role: 'user' });
|
||||||
|
t.after(app.close);
|
||||||
|
|
||||||
|
const read = await app.request('/admin-api/orchestrator/config');
|
||||||
|
assert.equal(read.status, 403);
|
||||||
|
|
||||||
|
const update = await app.request('/admin-api/orchestrator/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ config: { mode: 'active', executionEnabled: true } }),
|
||||||
|
});
|
||||||
|
assert.equal(update.status, 403);
|
||||||
|
assert.equal(app.calls.update.length, 0);
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
|
|||||||
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
|
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
|
||||||
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
|
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
|
||||||
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
|
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
|
||||||
|
import { OrchestratorPage } from './admin/pages/OrchestratorPage';
|
||||||
import { defaultHomePath } from './lib/routes';
|
import { defaultHomePath } from './lib/routes';
|
||||||
import { OpsLayout } from './ops/components/OpsLayout';
|
import { OpsLayout } from './ops/components/OpsLayout';
|
||||||
import { AnalyticsPage } from './ops/pages/AnalyticsPage';
|
import { AnalyticsPage } from './ops/pages/AnalyticsPage';
|
||||||
@@ -127,6 +128,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
|||||||
<Route path="memory-v2" element={<MemoryV2Page />} />
|
<Route path="memory-v2" element={<MemoryV2Page />} />
|
||||||
<Route path="mindsearch" element={<MindSearchPage />} />
|
<Route path="mindsearch" element={<MindSearchPage />} />
|
||||||
<Route path="skill-runtime" element={<SkillRuntimePage />} />
|
<Route path="skill-runtime" element={<SkillRuntimePage />} />
|
||||||
|
<Route path="orchestrator" element={<OrchestratorPage />} />
|
||||||
<Route path="providers" element={<ProvidersPage />} />
|
<Route path="providers" element={<ProvidersPage />} />
|
||||||
<Route path="wechat" element={<WechatPage />} />
|
<Route path="wechat" element={<WechatPage />} />
|
||||||
<Route path="asset-gateway" element={<AssetGatewayPage />} />
|
<Route path="asset-gateway" element={<AssetGatewayPage />} />
|
||||||
@@ -175,6 +177,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
|||||||
|| pathname.startsWith('/mindspace')
|
|| pathname.startsWith('/mindspace')
|
||||||
|| pathname.startsWith('/memory-v2')
|
|| pathname.startsWith('/memory-v2')
|
||||||
|| pathname.startsWith('/skill-runtime')
|
|| pathname.startsWith('/skill-runtime')
|
||||||
|
|| pathname.startsWith('/orchestrator')
|
||||||
|| pathname.startsWith('/providers')
|
|| pathname.startsWith('/providers')
|
||||||
|| pathname.startsWith('/wechat')
|
|| pathname.startsWith('/wechat')
|
||||||
|| pathname.startsWith('/asset-gateway')
|
|| pathname.startsWith('/asset-gateway')
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const NAV_SECTIONS: NavSection[] = [
|
|||||||
{ to: '/memory-v2', label: 'Memory V2' },
|
{ to: '/memory-v2', label: 'Memory V2' },
|
||||||
{ to: '/mindsearch', label: 'MindSearch' },
|
{ to: '/mindsearch', label: 'MindSearch' },
|
||||||
{ to: '/skill-runtime', label: 'Skill Runtime' },
|
{ to: '/skill-runtime', label: 'Skill Runtime' },
|
||||||
|
{ to: '/orchestrator', label: '任务编排' },
|
||||||
{ to: '/system-tests', label: '系统测试验证' },
|
{ to: '/system-tests', label: '系统测试验证' },
|
||||||
{ to: '/capabilities', label: '能力' },
|
{ to: '/capabilities', label: '能力' },
|
||||||
{ to: '/skills', label: '技能' },
|
{ to: '/skills', label: '技能' },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
import { getMindSpaceAdminConfig, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
|
import { getMindSpaceAdminConfig, getRybbitSsoUrl, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client';
|
||||||
|
|
||||||
const initial = {
|
const initial = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -16,7 +16,8 @@ export function AnalyticsConfigPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [opening, setOpening] = useState(false);
|
const [openingUmami, setOpeningUmami] = useState(false);
|
||||||
|
const [openingRybbit, setOpeningRybbit] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -52,23 +53,43 @@ export function AnalyticsConfigPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openUmami = async () => {
|
const openUmami = async () => {
|
||||||
setOpening(true);
|
setOpeningUmami(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
window.location.assign(await getUmamiSsoUrl());
|
window.location.assign(await getUmamiSsoUrl());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '无法打开 Umami');
|
setError(err instanceof Error ? err.message : '无法打开 Umami');
|
||||||
setOpening(false);
|
setOpeningUmami(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRybbit = async () => {
|
||||||
|
setOpeningRybbit(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
window.location.assign(await getRybbitSsoUrl());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '无法打开 Rybbit');
|
||||||
|
setOpeningRybbit(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <div className="admin-page">
|
return <div className="admin-page">
|
||||||
<div className="admin-page-head"><h2>Analytics 配置</h2><p className="muted">配置本地 Memind 生成页面的 Umami 统计。默认只连接本机 Umami,不涉及生产环境。</p></div>
|
<div className="admin-page-head"><h2>Analytics 配置</h2><p className="muted">进入统计后台,或配置 Memind 生成页面的 Umami 统计。Rybbit 行为分析复用下方「匿名化密钥」,并需在 Portal 环境变量中设置 MEMIND_RYBBIT_ENABLED、MEMIND_RYBBIT_SITE_ID。</p></div>
|
||||||
{error && <p className="banner banner-error">{error}</p>}
|
{error && <p className="banner banner-error">{error}</p>}
|
||||||
{message && <p className="banner banner-info">{message}</p>}
|
{message && <p className="banner banner-info">{message}</p>}
|
||||||
<section className="admin-card">
|
<section className="admin-card">
|
||||||
<h2>本地 Umami</h2>
|
<h2>Rybbit 行为分析</h2>
|
||||||
<div className="admin-actions" style={{ marginBottom: 16 }}><button type="button" className="send-btn" onClick={() => void openUmami()} disabled={opening || loading}>{opening ? '正在进入 Umami...' : '打开 Umami 分析后台'}</button></div>
|
<p className="muted">查看页面访问、用户会话、停留时间、点击和会话回放等行为明细。</p>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button type="button" className="send-btn" onClick={() => void openRybbit()} disabled={openingRybbit}>
|
||||||
|
{openingRybbit ? '正在进入 Rybbit...' : '打开 Rybbit 分析后台'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="admin-card">
|
||||||
|
<h2>Umami</h2>
|
||||||
|
<div className="admin-actions" style={{ marginBottom: 16 }}><button type="button" className="send-btn" onClick={() => void openUmami()} disabled={openingUmami || loading}>{openingUmami ? '正在进入 Umami...' : '打开 Umami 分析后台'}</button></div>
|
||||||
<form className="admin-form" onSubmit={save}>
|
<form className="admin-form" onSubmit={save}>
|
||||||
<label className="admin-form-row"><span>启用页面统计</span><input type="checkbox" checked={config.enabled} disabled={loading || busy} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} /></label>
|
<label className="admin-form-row"><span>启用页面统计</span><input type="checkbox" checked={config.enabled} disabled={loading || busy} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} /></label>
|
||||||
<label className="admin-form-row"><span>Website ID</span><input value={config.websiteId} disabled={loading || busy} onChange={(e) => setConfig({ ...config, websiteId: e.target.value })} placeholder="Umami Website ID" required={config.enabled} /></label>
|
<label className="admin-form-row"><span>Website ID</span><input value={config.websiteId} disabled={loading || busy} onChange={(e) => setConfig({ ...config, websiteId: e.target.value })} placeholder="Umami Website ID" required={config.enabled} /></label>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type PluginDraft = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
llmProviderKeyId: string;
|
llmProviderKeyId: string;
|
||||||
llmModel: string;
|
llmModel: string;
|
||||||
|
purposes: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
|
function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
|
||||||
@@ -20,6 +21,7 @@ function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
|
|||||||
provider: plugin.provider ?? '',
|
provider: plugin.provider ?? '',
|
||||||
llmProviderKeyId: plugin.llmProviderKeyId ?? '',
|
llmProviderKeyId: plugin.llmProviderKeyId ?? '',
|
||||||
llmModel: plugin.llmModel ?? '',
|
llmModel: plugin.llmModel ?? '',
|
||||||
|
purposes: { ...(plugin.purposes ?? {}) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +63,16 @@ export function AssetGatewayPage() {
|
|||||||
setDrafts((current) => ({ ...current, [pluginId]: { ...current[pluginId], ...patch } }));
|
setDrafts((current) => ({ ...current, [pluginId]: { ...current[pluginId], ...patch } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePurpose = (pluginId: string, purposeId: string, enabled: boolean) => {
|
||||||
|
setDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[pluginId]: {
|
||||||
|
...current[pluginId],
|
||||||
|
purposes: { ...(current[pluginId]?.purposes ?? {}), [purposeId]: enabled },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const saveGlobal = async (enabled: boolean) => {
|
const saveGlobal = async (enabled: boolean) => {
|
||||||
setBusy('global'); setMessage(null); setError(null);
|
setBusy('global'); setMessage(null); setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -81,6 +93,7 @@ export function AssetGatewayPage() {
|
|||||||
provider: draft.provider || null,
|
provider: draft.provider || null,
|
||||||
llmProviderKeyId: draft.llmProviderKeyId || null,
|
llmProviderKeyId: draft.llmProviderKeyId || null,
|
||||||
llmModel: draft.llmModel || null,
|
llmModel: draft.llmModel || null,
|
||||||
|
purposes: draft.purposes,
|
||||||
});
|
});
|
||||||
setConfig(result.config);
|
setConfig(result.config);
|
||||||
setDrafts(Object.fromEntries(result.config.plugins.map((item) => [item.pluginId, draftFromPlugin(item)])));
|
setDrafts(Object.fromEntries(result.config.plugins.map((item) => [item.pluginId, draftFromPlugin(item)])));
|
||||||
@@ -126,11 +139,21 @@ export function AssetGatewayPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-plugin-fields">
|
<div className="asset-plugin-fields">
|
||||||
<label>运行 Provider<select value={draft.provider} onChange={(event) => updateDraft(plugin.pluginId, { provider: event.target.value })} disabled={!draft.enabled}>
|
<label>运行 Provider<select value={draft.provider} onChange={(event) => {
|
||||||
|
const provider = event.target.value;
|
||||||
|
updateDraft(plugin.pluginId, {
|
||||||
|
provider,
|
||||||
|
...(provider === 'image_make' ? { llmProviderKeyId: '', llmModel: '' } : {}),
|
||||||
|
});
|
||||||
|
}} disabled={!draft.enabled}>
|
||||||
<option value="">选择 Provider</option>
|
<option value="">选择 Provider</option>
|
||||||
{plugin.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
{plugin.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
||||||
</select></label>
|
</select></label>
|
||||||
{plugin.supportsLlm ? <>
|
{plugin.pluginId === 'asset-generate' && draft.provider === 'image_make' ? (
|
||||||
|
<div className="asset-image-make-note">
|
||||||
|
image_make 独立管理本地模型,不读取统一模型中心密钥。
|
||||||
|
</div>
|
||||||
|
) : plugin.supportsLlm ? <>
|
||||||
<label>专属 LLM<select value={draft.llmProviderKeyId} onChange={(event) => updateDraft(plugin.pluginId, { llmProviderKeyId: event.target.value, llmModel: '' })} disabled={!draft.enabled}>
|
<label>专属 LLM<select value={draft.llmProviderKeyId} onChange={(event) => updateDraft(plugin.pluginId, { llmProviderKeyId: event.target.value, llmModel: '' })} disabled={!draft.enabled}>
|
||||||
<option value="">不使用 LLM</option>
|
<option value="">不使用 LLM</option>
|
||||||
{activeKeys.map((key) => <option key={key.id} value={key.id}>{key.name}</option>)}
|
{activeKeys.map((key) => <option key={key.id} value={key.id}>{key.name}</option>)}
|
||||||
@@ -140,6 +163,24 @@ export function AssetGatewayPage() {
|
|||||||
{selectedKey?.models.map((model) => <option key={model} value={model}>{model}</option>)}
|
{selectedKey?.models.map((model) => <option key={model} value={model}>{model}</option>)}
|
||||||
</select></label>
|
</select></label>
|
||||||
</> : <div className="asset-no-llm">确定性处理 · 不调用 LLM</div>}
|
</> : <div className="asset-no-llm">确定性处理 · 不调用 LLM</div>}
|
||||||
|
{plugin.pluginId === 'asset-generate' && plugin.purposeCatalog?.length ? (
|
||||||
|
<fieldset className="asset-purpose-fieldset" disabled={!draft.enabled || draft.provider !== 'image_make'}>
|
||||||
|
<legend>image_make 生效范围</legend>
|
||||||
|
<div className="asset-purpose-grid">
|
||||||
|
{plugin.purposeCatalog.map((purpose) => (
|
||||||
|
<label key={purpose.id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(draft.purposes[purpose.id])}
|
||||||
|
onChange={(event) => updatePurpose(plugin.pluginId, purpose.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{purpose.label}<small>{purpose.presetId}</small></span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p>信息流缩略图仍由 MindSpace 从封面源图派生,不会重复调用生图模型。</p>
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-plugin-footer"><span className={`asset-status ${draft.enabled ? 'is-on' : ''}`}>{draft.enabled ? '待保存为启用' : '默认安全关闭'}</span><button type="button" className="send-btn" disabled={busy === plugin.pluginId} onClick={() => void savePlugin(plugin)}>{busy === plugin.pluginId ? '保存中…' : '保存配置'}</button></div>
|
<div className="asset-plugin-footer"><span className={`asset-status ${draft.enabled ? 'is-on' : ''}`}>{draft.enabled ? '待保存为启用' : '默认安全关闭'}</span><button type="button" className="send-btn" disabled={busy === plugin.pluginId} onClick={() => void savePlugin(plugin)}>{busy === plugin.pluginId ? '保存中…' : '保存配置'}</button></div>
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -1,23 +1,75 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client';
|
import {
|
||||||
import type { MindSearchConfig } from '../../types';
|
getMindSearchConfig,
|
||||||
|
listLlmProviderKeys,
|
||||||
|
testLlmProviderKey,
|
||||||
|
testMindSearchService,
|
||||||
|
updateMindSearchConfig,
|
||||||
|
} from '../../api/client';
|
||||||
|
import type {
|
||||||
|
LlmProviderKeyRow,
|
||||||
|
MindSearchConfig,
|
||||||
|
MindSearchRoutes,
|
||||||
|
MindSearchService,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
const builtInServices: MindSearchService[] = [
|
||||||
|
{ id: 'searxng', name: 'SearXNG', kind: 'provider', adapter: 'searxng', capabilities: ['search.web', 'search.news'], endpoint: '', healthPath: '/config', enabled: false, timeoutMs: 8000, priority: 100 },
|
||||||
|
{ id: 'github', name: 'GitHub Code', kind: 'provider', adapter: 'github', capabilities: ['search.code'], endpoint: 'https://api.github.com/search/code', healthPath: '', enabled: false, timeoutMs: 8000, priority: 90 },
|
||||||
|
{ id: 'reader', name: 'Safe Reader', kind: 'reader', adapter: 'reader', capabilities: ['search.read'], endpoint: '', healthPath: '', enabled: false, timeoutMs: 8000, priority: 80 },
|
||||||
|
{ id: 'deep-search', name: 'TKMind Deep Search', kind: 'orchestrator', adapter: 'research-http', capabilities: ['search.web', 'research.plan', 'research.execute'], endpoint: 'http://127.0.0.1:20100', healthPath: '/health', enabled: false, timeoutMs: 120000, priority: 100, llmProviderKeyId: '', llmModel: '' },
|
||||||
|
];
|
||||||
|
|
||||||
const initial: MindSearchConfig = {
|
const initial: MindSearchConfig = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
mode: 'off',
|
mode: 'off',
|
||||||
providers: { searxng: false, github: false, reader: false },
|
providers: { searxng: false, github: false, reader: false },
|
||||||
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
|
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
|
||||||
|
services: builtInServices,
|
||||||
|
routes: { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: 'deep-search' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const routeLabels: Record<keyof MindSearchRoutes, string> = {
|
||||||
|
web: '普通网页搜索',
|
||||||
|
news: '新闻搜索',
|
||||||
|
code: '代码搜索',
|
||||||
|
read: '网页读取',
|
||||||
|
research: '深度研究',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeConfig(config: MindSearchConfig): MindSearchConfig {
|
||||||
|
const configuredServices = config.services ?? [];
|
||||||
|
const configuredServiceIds = new Set(configuredServices.map((service) => service.id));
|
||||||
|
const services = configuredServices.length
|
||||||
|
? [
|
||||||
|
...configuredServices,
|
||||||
|
...builtInServices.filter((service) => !configuredServiceIds.has(service.id)),
|
||||||
|
]
|
||||||
|
: builtInServices;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...initial,
|
||||||
|
...config,
|
||||||
|
settings: { ...initial.settings, ...config.settings },
|
||||||
|
services,
|
||||||
|
routes: { ...initial.routes, ...config.routes },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function MindSearchPage() {
|
export function MindSearchPage() {
|
||||||
const [config, setConfig] = useState(initial);
|
const [config, setConfig] = useState(initial);
|
||||||
|
const [llmKeys, setLlmKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||||
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
|
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
|
||||||
const [message, setMessage] = useState('加载配置中…');
|
const [message, setMessage] = useState('加载配置中…');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getMindSearchConfig()
|
Promise.all([
|
||||||
.then((result) => {
|
getMindSearchConfig(),
|
||||||
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
|
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
|
||||||
|
])
|
||||||
|
.then(([result, keys]) => {
|
||||||
|
setConfig(mergeConfig(result.config));
|
||||||
|
setLlmKeys(keys.filter((key) => key.status === 'active'));
|
||||||
setMeta(result);
|
setMeta(result);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
})
|
})
|
||||||
@@ -28,7 +80,7 @@ export function MindSearchPage() {
|
|||||||
setMessage('保存中…');
|
setMessage('保存中…');
|
||||||
try {
|
try {
|
||||||
const result = await updateMindSearchConfig(config);
|
const result = await updateMindSearchConfig(config);
|
||||||
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
|
setConfig(mergeConfig(result.config));
|
||||||
setMeta(result);
|
setMeta(result);
|
||||||
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
|
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -36,30 +88,207 @@ export function MindSearchPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleProvider = (key: keyof MindSearchConfig['providers']) =>
|
|
||||||
setConfig((current) => ({ ...current, providers: { ...current.providers, [key]: !current.providers[key] } }));
|
|
||||||
const updateSetting = (key: keyof MindSearchConfig['settings'], value: string | number) =>
|
const updateSetting = (key: keyof MindSearchConfig['settings'], value: string | number) =>
|
||||||
setConfig((current) => ({ ...current, settings: { ...current.settings, [key]: value } }));
|
setConfig((current) => ({ ...current, settings: { ...current.settings, [key]: value } }));
|
||||||
|
const updateService = (id: string, patch: Partial<MindSearchService>) =>
|
||||||
|
setConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
services: current.services.map((service) => service.id === id ? { ...service, ...patch } : service),
|
||||||
|
}));
|
||||||
|
const addService = () => {
|
||||||
|
const existingIds = new Set(config.services.map((service) => service.id));
|
||||||
|
let suffix = config.services.length + 1;
|
||||||
|
while (existingIds.has(`research-${suffix}`)) suffix += 1;
|
||||||
|
const id = `research-${suffix}`;
|
||||||
|
setConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
services: [...current.services, {
|
||||||
|
id,
|
||||||
|
name: 'Research Engine',
|
||||||
|
kind: 'orchestrator',
|
||||||
|
adapter: 'research-http',
|
||||||
|
capabilities: ['search.web', 'research.plan', 'research.execute'],
|
||||||
|
endpoint: 'http://127.0.0.1:20100',
|
||||||
|
healthPath: '/health',
|
||||||
|
enabled: false,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
priority: 100,
|
||||||
|
llmProviderKeyId: '',
|
||||||
|
llmModel: '',
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const removeService = (id: string) =>
|
||||||
|
setConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
services: current.services.filter((service) => service.id !== id),
|
||||||
|
routes: Object.fromEntries(
|
||||||
|
Object.entries(current.routes).map(([key, value]) => [key, value === id ? '' : value]),
|
||||||
|
) as unknown as MindSearchRoutes,
|
||||||
|
}));
|
||||||
|
const runServiceTest = async (id: string) => {
|
||||||
|
setMessage(`测试 ${id} 中…`);
|
||||||
|
try {
|
||||||
|
const result = await testMindSearchService(id);
|
||||||
|
setMessage(`${result.ok ? '通过' : '失败'}:${id};${result.message};${result.latencyMs}ms${result.resultCount == null ? '' : `;${result.resultCount} 条结果`}`);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : '服务测试失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const runLlmTest = async (service: MindSearchService) => {
|
||||||
|
if (!service.llmProviderKeyId || !service.llmModel) {
|
||||||
|
setMessage('请先选择 Deep Search 的 LLM 配置和模型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage(`测试 ${service.llmModel} 中…`);
|
||||||
|
try {
|
||||||
|
const result = await testLlmProviderKey(service.llmProviderKeyId, service.llmModel);
|
||||||
|
setMessage(result.ok
|
||||||
|
? `LLM 通过:${result.model ?? service.llmModel}${result.latencyMs == null ? '' : `;${result.latencyMs}ms`}`
|
||||||
|
: `LLM 失败:${result.message ?? '模型连接失败'}`);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : 'LLM 测试失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="admin-page">
|
<section className="admin-page">
|
||||||
<div className="admin-page-header"><div><h1>MindSearch</h1><p>可插拔外部搜索增强。关闭时完全保持原有搜索、记忆和上下文链路。</p></div></div>
|
<div className="admin-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>MindSearch</h1>
|
||||||
|
<p>可插拔搜索与研究服务。关闭时保持原有搜索、记忆和上下文链路。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<h2>总开关与模式</h2>
|
<h2>总开关与模式</h2>
|
||||||
<label><input type="checkbox" checked={config.enabled} onChange={(event) => setConfig({ ...config, enabled: event.target.checked, mode: event.target.checked ? (config.mode === 'off' ? 'shadow' : config.mode) : 'off' })} /> 启用 MindSearch</label>
|
<div className="admin-form">
|
||||||
<label>运行模式 <select value={config.mode} onChange={(event) => setConfig({ ...config, mode: event.target.value as MindSearchConfig['mode'] })}><option value="off">关闭</option><option value="shadow">Shadow(只记录,不注入)</option><option value="assist">Assist(补充工具)</option></select></label>
|
<label className="search-service-toggle">
|
||||||
<h2>Provider</h2>
|
<input
|
||||||
<fieldset><legend>可用 Provider</legend>
|
type="checkbox"
|
||||||
<label><input type="checkbox" checked={config.providers.searxng} onChange={() => toggleProvider('searxng')} /> SearXNG Web/News</label>
|
checked={config.enabled}
|
||||||
<label><input type="checkbox" checked={config.providers.github} onChange={() => toggleProvider('github')} /> GitHub Code(Token 通过环境变量配置,不在页面回显)</label>
|
onChange={(event) => setConfig({
|
||||||
<label><input type="checkbox" checked={config.providers.reader} onChange={() => toggleProvider('reader')} /> Reader(阻止 localhost/内网地址)</label>
|
...config,
|
||||||
</fieldset>
|
enabled: event.target.checked,
|
||||||
|
mode: event.target.checked ? (config.mode === 'off' ? 'shadow' : config.mode) : 'off',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span>启用 MindSearch</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>运行模式</span>
|
||||||
|
<select value={config.mode} onChange={(event) => setConfig({ ...config, mode: event.target.value as MindSearchConfig['mode'] })}>
|
||||||
|
<option value="off">关闭</option>
|
||||||
|
<option value="shadow">Shadow(只记录,不注入)</option>
|
||||||
|
<option value="assist">Assist(补充工具)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>服务注册表</h2>
|
||||||
|
<p>服务独立部署;后台只管理连接、路由和健康检查,不直接操作 Docker 或生产进程。测试使用已保存配置。</p>
|
||||||
|
<div className="search-service-grid">
|
||||||
|
{config.services.map((service) => {
|
||||||
|
const selectedLlmKey = llmKeys.find((key) => key.id === service.llmProviderKeyId);
|
||||||
|
return (
|
||||||
|
<fieldset key={service.id} className="search-service-card">
|
||||||
|
<legend>{service.name}({service.id})</legend>
|
||||||
|
<label className="search-service-toggle"><input type="checkbox" checked={service.enabled} onChange={(event) => updateService(service.id, { enabled: event.target.checked })} /><span>启用服务</span></label>
|
||||||
|
<div className="admin-form">
|
||||||
|
<label><span>名称</span><input value={service.name} onChange={(event) => updateService(service.id, { name: event.target.value })} /></label>
|
||||||
|
<label>
|
||||||
|
<span>类型</span>
|
||||||
|
<select value={service.kind} onChange={(event) => updateService(service.id, { kind: event.target.value as MindSearchService['kind'] })}>
|
||||||
|
<option value="provider">Provider</option>
|
||||||
|
<option value="reader">Reader</option>
|
||||||
|
<option value="orchestrator">Orchestrator</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>适配器</span>
|
||||||
|
<select value={service.adapter} onChange={(event) => updateService(service.id, { adapter: event.target.value as MindSearchService['adapter'] })}>
|
||||||
|
<option value="searxng">SearXNG</option>
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="reader">Reader</option>
|
||||||
|
<option value="research-http">Research HTTP</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label><span>健康检查路径</span><input value={service.healthPath} placeholder="/health" onChange={(event) => updateService(service.id, { healthPath: event.target.value })} /></label>
|
||||||
|
<label className="form-span-all"><span>Endpoint</span><input value={service.endpoint} placeholder="http://127.0.0.1:20100" onChange={(event) => updateService(service.id, { endpoint: event.target.value })} /></label>
|
||||||
|
<label className="form-span-all"><span>Capabilities</span><input value={service.capabilities.join(', ')} onChange={(event) => updateService(service.id, { capabilities: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) })} /></label>
|
||||||
|
<label><span>超时(毫秒)</span><input type="number" min={1000} max={120000} value={service.timeoutMs} onChange={(event) => updateService(service.id, { timeoutMs: Number(event.target.value) })} /></label>
|
||||||
|
<label><span>优先级</span><input type="number" min={1} max={1000} value={service.priority} onChange={(event) => updateService(service.id, { priority: Number(event.target.value) })} /></label>
|
||||||
|
{service.adapter === 'research-http' && <>
|
||||||
|
<label className="form-span-all">
|
||||||
|
<span>规划与报告 LLM(可选)</span>
|
||||||
|
<select
|
||||||
|
value={service.llmProviderKeyId ?? ''}
|
||||||
|
onChange={(event) => {
|
||||||
|
const key = llmKeys.find((item) => item.id === event.target.value);
|
||||||
|
updateService(service.id, {
|
||||||
|
llmProviderKeyId: event.target.value,
|
||||||
|
llmModel: key?.defaultModel ?? '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">不使用 LLM(确定性降级)</option>
|
||||||
|
{service.llmProviderKeyId && !selectedLlmKey
|
||||||
|
&& <option value={service.llmProviderKeyId}>原 LLM 配置已停用或删除</option>}
|
||||||
|
{llmKeys.map((key) => (
|
||||||
|
<option key={key.id} value={key.id}>{key.name} · {key.providerLabel}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-span-all">
|
||||||
|
<span>模型</span>
|
||||||
|
<select
|
||||||
|
value={service.llmModel ?? ''}
|
||||||
|
disabled={!selectedLlmKey}
|
||||||
|
onChange={(event) => updateService(service.id, { llmModel: event.target.value })}
|
||||||
|
>
|
||||||
|
{!selectedLlmKey && <option value="">请先选择 LLM 配置</option>}
|
||||||
|
{selectedLlmKey && service.llmModel && !selectedLlmKey.models.includes(service.llmModel)
|
||||||
|
&& <option value={service.llmModel}>原模型已不可用:{service.llmModel}</option>}
|
||||||
|
{selectedLlmKey?.models.map((model) => <option key={model} value={model}>{model}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="search-llm-hint form-span-all">密钥复用“统一模型中心”,不会保存到 MindSearch 配置或下发给 Deep Search 服务。</p>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
<div className="search-service-actions">
|
||||||
|
<button className="send-btn" type="button" onClick={() => runServiceTest(service.id)}>测试已保存配置</button>
|
||||||
|
{service.adapter === 'research-http' && service.llmProviderKeyId && service.llmModel
|
||||||
|
&& <button type="button" onClick={() => runLlmTest(service)}>测试 LLM</button>}
|
||||||
|
{!['searxng', 'github', 'reader', 'deep-search'].includes(service.id) && <button type="button" onClick={() => removeService(service.id)}>移除</button>}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button className="send-btn" type="button" onClick={addService}>添加 Research Service</button>
|
||||||
|
|
||||||
|
<h2>能力路由</h2>
|
||||||
|
<div className="admin-form">
|
||||||
|
{(Object.keys(routeLabels) as Array<keyof MindSearchRoutes>).map((route) => (
|
||||||
|
<label key={route}>
|
||||||
|
<span>{routeLabels[route]}</span>
|
||||||
|
<select
|
||||||
|
value={config.routes[route]}
|
||||||
|
onChange={(event) => setConfig((current) => ({ ...current, routes: { ...current.routes, [route]: event.target.value } }))}
|
||||||
|
>
|
||||||
|
<option value="">未配置</option>
|
||||||
|
{config.services.map((service) => <option key={service.id} value={service.id}>{service.name}({service.id})</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>运行参数</h2>
|
<h2>运行参数</h2>
|
||||||
<label>SearXNG 地址 <input value={config.settings.searxngEndpoint} placeholder="http://127.0.0.1:8080/search" onChange={(event) => updateSetting('searxngEndpoint', event.target.value)} /></label>
|
<div className="admin-form">
|
||||||
<label>最大结果数 <input type="number" min={1} max={20} value={config.settings.maxResults} onChange={(event) => updateSetting('maxResults', Number(event.target.value))} /></label>
|
<label><span>最大结果数</span><input type="number" min={1} max={20} value={config.settings.maxResults} onChange={(event) => updateSetting('maxResults', Number(event.target.value))} /></label>
|
||||||
<label>Provider 超时(毫秒) <input type="number" min={1000} max={30000} value={config.settings.timeoutMs} onChange={(event) => updateSetting('timeoutMs', Number(event.target.value))} /></label>
|
<label><span>默认 Provider 超时(毫秒)</span><input type="number" min={1000} max={30000} value={config.settings.timeoutMs} onChange={(event) => updateSetting('timeoutMs', Number(event.target.value))} /></label>
|
||||||
<label>Reader 最大字符数 <input type="number" min={1000} max={50000} value={config.settings.readerMaxChars} onChange={(event) => updateSetting('readerMaxChars', Number(event.target.value))} /></label>
|
<label><span>Reader 最大字符数</span><input type="number" min={1000} max={50000} value={config.settings.readerMaxChars} onChange={(event) => updateSetting('readerMaxChars', Number(event.target.value))} /></label>
|
||||||
<button type="button" onClick={save}>保存配置</button>
|
</div>
|
||||||
|
<button className="send-btn" type="button" onClick={save}>保存配置</button>
|
||||||
{message && <p role="status">{message}</p>}
|
{message && <p role="status">{message}</p>}
|
||||||
<p>配置来源:{meta.source === 'admin' ? '数据库' : '环境变量默认值'};最后更新:{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
|
<p>配置来源:{meta.source === 'admin' ? '数据库' : '环境变量默认值'};最后更新:{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
getOrchestratorCanaryReadiness,
|
||||||
|
getOrchestratorConfig,
|
||||||
|
getOrchestratorExecutionPlans,
|
||||||
|
getOrchestratorRuntime,
|
||||||
|
getOrchestratorShadowRun,
|
||||||
|
getOrchestratorShadowRuns,
|
||||||
|
updateOrchestratorConfig,
|
||||||
|
} from '../../api/client';
|
||||||
|
import type {
|
||||||
|
OrchestratorCanaryReadiness,
|
||||||
|
OrchestratorConfig,
|
||||||
|
OrchestratorConfigState,
|
||||||
|
OrchestratorExecutionPlanList,
|
||||||
|
OrchestratorServiceHealth,
|
||||||
|
OrchestratorShadowRunDetail,
|
||||||
|
OrchestratorShadowRunList,
|
||||||
|
} from '../../types/orchestrator';
|
||||||
|
|
||||||
|
const runtimeReasons: Record<string, string> = {
|
||||||
|
mode_off: '当前为关闭模式,所有任务继续由 Native Agent Run 执行。',
|
||||||
|
service_url_missing: '尚未配置 Orchestrator 服务地址,LangGraph 不会接管任务。',
|
||||||
|
kill_switch: '环境级紧急熔断已开启,强制回退 Native。',
|
||||||
|
execution_gate_disabled: '执行交接闸门被硬锁定,当前仅生成 Dry-run 决策。',
|
||||||
|
environment_execution_gate_disabled: 'memindadm 已请求交接,但环境闸门仍关闭。',
|
||||||
|
};
|
||||||
|
|
||||||
|
const readinessLabels: Record<string, string> = {
|
||||||
|
shadow_mode: '保持 Shadow',
|
||||||
|
service_healthy: '服务健康',
|
||||||
|
durable_checkpoint: '持久化 checkpoint',
|
||||||
|
durable_executor_job_store: '持久化 Executor Job Store',
|
||||||
|
observe_only: '仅观察不执行',
|
||||||
|
sample_volume: '有效样本量',
|
||||||
|
shadow_success_rate: 'Shadow 成功率',
|
||||||
|
shadow_skip_rate: 'Shadow 跳过率',
|
||||||
|
latency_coverage: '延迟数据覆盖率',
|
||||||
|
latency_p95: '延迟 P95',
|
||||||
|
native_settled_rate: 'Native 终态覆盖率',
|
||||||
|
session_coverage: '真实会话覆盖',
|
||||||
|
sample_freshness: '最近样本新鲜度',
|
||||||
|
complete_window: '统计窗口完整',
|
||||||
|
};
|
||||||
|
|
||||||
|
function listToText(values: string[]) {
|
||||||
|
return values.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToList(value: string) {
|
||||||
|
return [...new Set(value.split(/\r?\n|,/).map((item) => item.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: number | null) {
|
||||||
|
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value?: number | null) {
|
||||||
|
return value == null ? '—' : `${(value * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLatency(value?: number | null) {
|
||||||
|
return value == null ? '—' : `${value} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(value: string) {
|
||||||
|
return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-6)}` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readinessValue(check: OrchestratorCanaryReadiness['checks'][number]) {
|
||||||
|
if (check.actual == null) return '无数据';
|
||||||
|
if (['shadow_success_rate', 'latency_coverage', 'native_settled_rate'].includes(check.id)) {
|
||||||
|
return formatPercent(Number(check.actual));
|
||||||
|
}
|
||||||
|
if (check.id === 'latency_p95') return formatLatency(Number(check.actual));
|
||||||
|
if (check.id === 'sample_freshness') return `${Number(check.actual).toFixed(1)} 小时`;
|
||||||
|
if (typeof check.actual === 'boolean') return check.actual ? '是' : '否';
|
||||||
|
return String(check.actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrchestratorPage() {
|
||||||
|
const [state, setState] = useState<OrchestratorConfigState | null>(null);
|
||||||
|
const [draft, setDraft] = useState<OrchestratorConfig | null>(null);
|
||||||
|
const [workflowAllowlist, setWorkflowAllowlist] = useState('');
|
||||||
|
const [userAllowlist, setUserAllowlist] = useState('');
|
||||||
|
const [shadow, setShadow] = useState<OrchestratorShadowRunList | null>(null);
|
||||||
|
const [plans, setPlans] = useState<OrchestratorExecutionPlanList | null>(null);
|
||||||
|
const [readiness, setReadiness] = useState<OrchestratorCanaryReadiness | null>(null);
|
||||||
|
const [health, setHealth] = useState<OrchestratorServiceHealth | null>(null);
|
||||||
|
const [detail, setDetail] = useState<OrchestratorShadowRunDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [configState, shadowState, planState, readinessState] = await Promise.all([
|
||||||
|
getOrchestratorConfig(),
|
||||||
|
getOrchestratorShadowRuns({ hours: 24, limit: 100 }),
|
||||||
|
getOrchestratorExecutionPlans({ hours: 24, limit: 100 }),
|
||||||
|
getOrchestratorCanaryReadiness(),
|
||||||
|
]);
|
||||||
|
setState(configState);
|
||||||
|
setDraft(configState.config);
|
||||||
|
setWorkflowAllowlist(listToText(configState.config.workflowAllowlist));
|
||||||
|
setUserAllowlist(listToText(configState.config.userAllowlist));
|
||||||
|
setShadow(shadowState);
|
||||||
|
setPlans(planState);
|
||||||
|
setReadiness(readinessState);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Orchestrator 数据加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizedDraft = useMemo(() => draft ? {
|
||||||
|
...draft,
|
||||||
|
workflowAllowlist: textToList(workflowAllowlist),
|
||||||
|
userAllowlist: textToList(userAllowlist),
|
||||||
|
} : null, [draft, workflowAllowlist, userAllowlist]);
|
||||||
|
|
||||||
|
const dirty = Boolean(
|
||||||
|
state && normalizedDraft && JSON.stringify(state.config) !== JSON.stringify(normalizedDraft),
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!normalizedDraft) return;
|
||||||
|
if (['canary', 'active'].includes(normalizedDraft.mode) && !normalizedDraft.serviceUrl) {
|
||||||
|
setError('Canary / Active 模式必须先配置 Orchestrator URL。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalizedDraft.executionEnabled
|
||||||
|
&& !window.confirm('确认请求执行交接?环境闸门关闭时仍只会 Dry-run。')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const result = await updateOrchestratorConfig(normalizedDraft);
|
||||||
|
setState(result);
|
||||||
|
setDraft(result.config);
|
||||||
|
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
|
||||||
|
setUserAllowlist(listToText(result.config.userAllowlist));
|
||||||
|
setMessage(`Orchestrator 配置 v${result.configVersion} 已保存。`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkHealth = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await getOrchestratorRuntime();
|
||||||
|
setHealth(result.serviceHealth);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '服务检查失败');
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = async (runId: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setDetail(await getOrchestratorShadowRun(runId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Run 详情加载失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="admin-page"><p>正在加载 Workflow Orchestrator…</p></div>;
|
||||||
|
if (!state || !draft) {
|
||||||
|
return <div className="admin-page"><p className="banner banner-error">{error ?? '配置不可用'}</p></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeMessage = state.runtime.reason
|
||||||
|
? (runtimeReasons[state.runtime.reason] ?? state.runtime.reason)
|
||||||
|
: state.runtime.shadowsLangGraph
|
||||||
|
? 'Shadow 已生效:Native 继续执行,LangGraph 只观察。'
|
||||||
|
: state.runtime.executesLangGraph
|
||||||
|
? '执行交接已开启。'
|
||||||
|
: '配置有效。';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page orchestrator-page">
|
||||||
|
<div className="admin-page-head">
|
||||||
|
<h2>Workflow Orchestrator</h2>
|
||||||
|
<p className="muted">
|
||||||
|
LangGraph 以可插拔服务接入;Off 与 Shadow 不改变现有 Native Agent Run 执行路径。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="banner banner-error">{error}</p>}
|
||||||
|
{message && <p className="banner banner-info">{message}</p>}
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<div className="orchestrator-summary">
|
||||||
|
<div><span>配置版本</span><strong>v{state.configVersion}</strong></div>
|
||||||
|
<div><span>运行状态</span><strong>{state.runtime.effective ? '有效' : '安全回退'}</strong></div>
|
||||||
|
<div><span>配置来源</span><strong>{state.source}</strong></div>
|
||||||
|
<div><span>执行交接</span><strong>{state.runtime.executionHandoff.enabled ? '已开启' : 'Dry-run 锁定'}</strong></div>
|
||||||
|
<div><span>更新时间</span><strong>{formatTime(state.updatedAt)}</strong></div>
|
||||||
|
</div>
|
||||||
|
<p className={`orchestrator-state ${state.runtime.reason === 'kill_switch' ? 'danger' : ''}`}>
|
||||||
|
{runtimeMessage}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<div className="orchestrator-section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Executor Gateway</h2>
|
||||||
|
<p className="muted">执行器默认关闭,最终状态以 Orchestrator 健康检查为准。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="orchestrator-executors">
|
||||||
|
{state.executors.map((executor) => (
|
||||||
|
<article key={executor.id}>
|
||||||
|
<div><strong>{executor.label}</strong><span>{executor.status}</span></div>
|
||||||
|
<p>{executor.capabilities.join(' · ') || '无能力声明'}</p>
|
||||||
|
<small>dispatch {executor.dispatchImplemented ? 'implemented' : 'disabled'}</small>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<div className="orchestrator-section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Shadow 运行观测</h2>
|
||||||
|
<p className="muted">最近 24 小时 Native 与 LangGraph 观察结果,不改变执行权。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => void load()}>刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{readiness && (
|
||||||
|
<div className={`orchestrator-readiness ${readiness.ready ? 'ready' : ''}`}>
|
||||||
|
<div>
|
||||||
|
<strong>Canary 准入评估</strong>
|
||||||
|
<b>{readiness.ready ? '达到门槛,等待人工审批' : '继续保持 Shadow'}</b>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
有效样本 {readiness.samples.eligibleObservations} / {readiness.thresholds.minObservations}
|
||||||
|
{' · '}排除 smoke {readiness.samples.excludedSynthetic}
|
||||||
|
{' · '}真实会话 {readiness.samples.distinctSessions} / {readiness.thresholds.minDistinctSessions}
|
||||||
|
</p>
|
||||||
|
<div className="orchestrator-checks">
|
||||||
|
{readiness.checks.map((check) => (
|
||||||
|
<span key={check.id} className={check.passed ? 'passed' : 'blocked'}>
|
||||||
|
{check.passed ? '通过' : '阻塞'} · {readinessLabels[check.id] ?? check.id}:{readinessValue(check)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="orchestrator-metrics">
|
||||||
|
<div><span>观察请求</span><strong>{shadow?.metrics.observations ?? '—'}</strong></div>
|
||||||
|
<div><span>成功率</span><strong>{formatPercent(shadow?.metrics.successRate)}</strong></div>
|
||||||
|
<div><span>失败数</span><strong>{shadow?.metrics.failures ?? '—'}</strong></div>
|
||||||
|
<div><span>延迟 P95</span><strong>{formatLatency(shadow?.metrics.latencyP95Ms)}</strong></div>
|
||||||
|
<div><span>Native 成功 / 失败</span><strong>{shadow ? `${shadow.metrics.nativeSucceeded} / ${shadow.metrics.nativeFailed}` : '—'}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead><tr><th>时间</th><th>Run</th><th>Shadow</th><th>Native</th><th>延迟</th><th>任务 / Adapter</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{(shadow?.runs ?? []).map((run) => (
|
||||||
|
<tr key={run.eventId}>
|
||||||
|
<td>{formatTime(run.observedAt)}</td>
|
||||||
|
<td><button type="button" className="ghost-btn" onClick={() => void openDetail(run.runId)}>{shortId(run.runId)}</button></td>
|
||||||
|
<td>{run.shadowStatus}</td>
|
||||||
|
<td>{run.nativeStatus}</td>
|
||||||
|
<td>{formatLatency(run.latencyMs)}</td>
|
||||||
|
<td>{run.taskType ?? '—'}{run.synthetic ? '(smoke)' : ''}<small>{run.executorAdapter ?? '—'}</small></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!shadow?.runs.length && <tr><td colSpan={6}>当前没有 Shadow 结果。</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail && (
|
||||||
|
<div className="orchestrator-detail">
|
||||||
|
<div className="orchestrator-section-head">
|
||||||
|
<div><strong>Run 详情:{detail.native.runId}</strong><p className="muted">Native {detail.native.status} · LangGraph {detail.remote.available ? 'available' : 'unavailable'}</p></div>
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => setDetail(null)}>关闭</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<pre>{JSON.stringify(detail.remote.state, null, 2)}</pre>
|
||||||
|
<pre>{JSON.stringify(detail.remote.events, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<div className="orchestrator-section-head">
|
||||||
|
<div><h2>执行路由 Dry-run</h2><p className="muted">候选路由仅观测,实际执行引擎必须保持 Native。</p></div>
|
||||||
|
</div>
|
||||||
|
{(plans?.metrics.handoffAllowed ?? 0) > 0 && (
|
||||||
|
<p className="banner banner-error">检测到 handoffAllowed=true,请立即检查执行闸门。</p>
|
||||||
|
)}
|
||||||
|
<div className="orchestrator-metrics">
|
||||||
|
<div><span>路由决策</span><strong>{plans?.metrics.decisions ?? '—'}</strong></div>
|
||||||
|
<div><span>候选命中率</span><strong>{formatPercent(plans?.metrics.candidateSelectionRate)}</strong></div>
|
||||||
|
<div><span>LangGraph / Native</span><strong>{plans ? `${plans.metrics.candidateSelections} / ${plans.metrics.nativeSelections}` : '—'}</strong></div>
|
||||||
|
<div><span>Native 终态覆盖</span><strong>{formatPercent(plans?.metrics.nativeSettledRate)}</strong></div>
|
||||||
|
<div><span>真实会话</span><strong>{plans?.metrics.distinctSessions ?? '—'}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead><tr><th>时间</th><th>Run</th><th>模式</th><th>候选 → 实际</th><th>原因</th><th>Native</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{(plans?.plans ?? []).map((plan) => (
|
||||||
|
<tr key={plan.eventId}>
|
||||||
|
<td>{formatTime(plan.plannedAt)}</td>
|
||||||
|
<td className="mono">{shortId(plan.runId)}</td>
|
||||||
|
<td>{plan.mode ?? '—'}</td>
|
||||||
|
<td>{plan.candidateEngine} → {plan.effectiveEngine}</td>
|
||||||
|
<td>{plan.candidateReason ?? plan.reason ?? '—'}</td>
|
||||||
|
<td>{plan.nativeStatus}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!plans?.plans.length && <tr><td colSpan={6}>当前没有 Dry-run 路由决策。</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<h2>路由与服务配置</h2>
|
||||||
|
<div className="orchestrator-form-grid">
|
||||||
|
<label><span>运行模式</span><select value={draft.mode} onChange={(event) => setDraft({ ...draft, mode: event.target.value as OrchestratorConfig['mode'] })}>
|
||||||
|
<option value="off">Off — Native only</option>
|
||||||
|
<option value="shadow">Shadow — Native 执行,LangGraph 观察</option>
|
||||||
|
<option value="canary">Canary — 白名单/百分比灰度</option>
|
||||||
|
<option value="active">Active — 工作流白名单接管</option>
|
||||||
|
</select></label>
|
||||||
|
<label><span>主执行引擎</span><select value={draft.primaryEngine} onChange={(event) => setDraft({ ...draft, primaryEngine: event.target.value })}>
|
||||||
|
{state.engines.map((engine) => <option key={engine.id} value={engine.id}>{engine.label}{engine.configured ? '' : '(未配置)'}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label><span>灰度百分比</span><input type="number" min={0} max={100} value={draft.rolloutPercent} onChange={(event) => setDraft({ ...draft, rolloutPercent: Number(event.target.value) })} /></label>
|
||||||
|
<label><span>请求超时(毫秒)</span><input type="number" min={500} max={60000} value={draft.requestTimeoutMs} onChange={(event) => setDraft({ ...draft, requestTimeoutMs: Number(event.target.value) })} /></label>
|
||||||
|
<label className="wide"><span>Orchestrator URL</span><input value={draft.serviceUrl} onChange={(event) => setDraft({ ...draft, serviceUrl: event.target.value })} placeholder="http://127.0.0.1:8093" /></label>
|
||||||
|
</div>
|
||||||
|
<div className="orchestrator-health">
|
||||||
|
<span>{health ? `${health.ok ? '健康' : '不可用'}(${health.status},${health.latencyMs} ms)` : '尚未检查服务连接'}</span>
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => void checkHealth()} disabled={checking}>{checking ? '检查中…' : '测试连接'}</button>
|
||||||
|
</div>
|
||||||
|
<div className="orchestrator-toggles">
|
||||||
|
<label><input type="checkbox" checked={draft.requireHealthy} onChange={(event) => setDraft({ ...draft, requireHealthy: event.target.checked })} />接管前要求服务健康</label>
|
||||||
|
<label><input type="checkbox" checked={draft.fallbackToNative} onChange={(event) => setDraft({ ...draft, fallbackToNative: event.target.checked })} />不可用时回退 Native</label>
|
||||||
|
<label className="handoff"><input type="checkbox" checked={draft.executionEnabled} onChange={(event) => setDraft({ ...draft, executionEnabled: event.target.checked })} />请求启用执行交接(仍受环境闸门与 Kill Switch 约束)</label>
|
||||||
|
</div>
|
||||||
|
<div className="orchestrator-allowlists">
|
||||||
|
<label><span>工作流白名单</span><textarea rows={6} value={workflowAllowlist} onChange={(event) => setWorkflowAllowlist(event.target.value)} /></label>
|
||||||
|
<label><span>用户白名单</span><textarea rows={6} value={userAllowlist} onChange={(event) => setUserAllowlist(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions orchestrator-actions">
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={saving}>放弃修改</button>
|
||||||
|
<button type="button" className="send-btn" onClick={() => void save()} disabled={saving || !dirty}>{saving ? '保存中…' : '保存配置'}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,7 +49,17 @@ import type {
|
|||||||
WechatMessage,
|
WechatMessage,
|
||||||
WechatWebNotification,
|
WechatWebNotification,
|
||||||
MindSearchConfig,
|
MindSearchConfig,
|
||||||
|
MindSearchServiceTestResult,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import type {
|
||||||
|
OrchestratorCanaryReadiness,
|
||||||
|
OrchestratorConfig,
|
||||||
|
OrchestratorConfigState,
|
||||||
|
OrchestratorExecutionPlanList,
|
||||||
|
OrchestratorRuntimeState,
|
||||||
|
OrchestratorShadowRunDetail,
|
||||||
|
OrchestratorShadowRunList,
|
||||||
|
} from '../types/orchestrator';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
@@ -307,6 +317,7 @@ export async function updateAssetPluginConfig(
|
|||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
llmProviderKeyId?: string | null;
|
llmProviderKeyId?: string | null;
|
||||||
llmModel?: string | null;
|
llmModel?: string | null;
|
||||||
|
purposes?: Record<string, boolean>;
|
||||||
},
|
},
|
||||||
): Promise<{ ok: boolean; config: AssetGatewayConfig }> {
|
): Promise<{ ok: boolean; config: AssetGatewayConfig }> {
|
||||||
return portalFetch(`/admin-api/asset-gateway/plugins/${encodeURIComponent(pluginId)}`, {
|
return portalFetch(`/admin-api/asset-gateway/plugins/${encodeURIComponent(pluginId)}`, {
|
||||||
@@ -325,6 +336,11 @@ export async function getUmamiSsoUrl(): Promise<string> {
|
|||||||
return result.url;
|
return result.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRybbitSsoUrl(): Promise<string> {
|
||||||
|
const result = await portalFetch<{ url: string }>('/admin-api/analytics/rybbit-sso');
|
||||||
|
return result.url;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateMindSpaceAdminConfig(
|
export async function updateMindSpaceAdminConfig(
|
||||||
payload: Partial<MindSpaceAdminConfig> & { analytics?: MindSpaceAnalyticsUpdate },
|
payload: Partial<MindSpaceAdminConfig> & { analytics?: MindSpaceAnalyticsUpdate },
|
||||||
): Promise<MindSpaceAdminConfig> {
|
): Promise<MindSpaceAdminConfig> {
|
||||||
@@ -668,6 +684,67 @@ export async function updateMindSearchConfig(config: Partial<MindSearchConfig>):
|
|||||||
return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) });
|
return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testMindSearchService(serviceId: string): Promise<MindSearchServiceTestResult> {
|
||||||
|
return portalFetch(`/admin-api/mindsearch/services/${encodeURIComponent(serviceId)}/test`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workflow Orchestrator ─────────────────────────────
|
||||||
|
|
||||||
|
export async function getOrchestratorConfig() {
|
||||||
|
return portalFetch<OrchestratorConfigState>('/admin-api/orchestrator/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrchestratorConfig(config: OrchestratorConfig) {
|
||||||
|
return portalFetch<OrchestratorConfigState>('/admin-api/orchestrator/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ config }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrchestratorRuntime() {
|
||||||
|
return portalFetch<OrchestratorRuntimeState>('/admin-api/orchestrator/runtime');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrchestratorShadowRuns(params: {
|
||||||
|
hours?: number;
|
||||||
|
limit?: number;
|
||||||
|
status?: 'all' | 'succeeded' | 'failed';
|
||||||
|
} = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.hours) query.set('hours', String(params.hours));
|
||||||
|
if (params.limit) query.set('limit', String(params.limit));
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
return portalFetch<OrchestratorShadowRunList>(
|
||||||
|
`/admin-api/orchestrator/shadow-runs?${query}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrchestratorExecutionPlans(params: {
|
||||||
|
hours?: number;
|
||||||
|
limit?: number;
|
||||||
|
selection?: 'all' | 'candidate' | 'native';
|
||||||
|
} = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.hours) query.set('hours', String(params.hours));
|
||||||
|
if (params.limit) query.set('limit', String(params.limit));
|
||||||
|
if (params.selection) query.set('selection', params.selection);
|
||||||
|
return portalFetch<OrchestratorExecutionPlanList>(
|
||||||
|
`/admin-api/orchestrator/execution-plans?${query}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrchestratorCanaryReadiness() {
|
||||||
|
return portalFetch<OrchestratorCanaryReadiness>(
|
||||||
|
'/admin-api/orchestrator/canary-readiness',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrchestratorShadowRun(runId: string) {
|
||||||
|
return portalFetch<OrchestratorShadowRunDetail>(
|
||||||
|
`/admin-api/orchestrator/shadow-runs/${encodeURIComponent(runId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Policies ──────────────────────────────────────────
|
// ── Policies ──────────────────────────────────────────
|
||||||
|
|
||||||
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
||||||
|
|||||||
+305
@@ -446,6 +446,212 @@ body,
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Workflow Orchestrator ─────────────────────────── */
|
||||||
|
|
||||||
|
.orchestrator-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-page .admin-card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-page h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-summary,
|
||||||
|
.orchestrator-executors,
|
||||||
|
.orchestrator-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-summary > div,
|
||||||
|
.orchestrator-metrics > div,
|
||||||
|
.orchestrator-executors article {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-summary span,
|
||||||
|
.orchestrator-metrics span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-state,
|
||||||
|
.orchestrator-readiness {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(224, 175, 91, 0.35);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(224, 175, 91, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-state.danger {
|
||||||
|
border-color: rgba(224, 86, 86, 0.4);
|
||||||
|
background: rgba(224, 86, 86, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-section-head,
|
||||||
|
.orchestrator-executors article > div,
|
||||||
|
.orchestrator-readiness > div,
|
||||||
|
.orchestrator-health {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-executors article p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-executors article span {
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-readiness.ready {
|
||||||
|
border-color: rgba(70, 176, 120, 0.4);
|
||||||
|
background: rgba(70, 176, 120, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-readiness p {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-checks {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
align-items: start !important;
|
||||||
|
justify-content: stretch !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-checks span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-checks .passed {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-checks .blocked {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-metrics {
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-table small,
|
||||||
|
.admin-table td small {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-detail {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-detail > div:last-child {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-detail pre {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-form-grid,
|
||||||
|
.orchestrator-allowlists {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-form-grid label,
|
||||||
|
.orchestrator-allowlists label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-form-grid .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-health {
|
||||||
|
margin: 14px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-toggles {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-toggles label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-toggles input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-toggles .handoff {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(224, 175, 91, 0.35);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(224, 175, 91, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.orchestrator-form-grid,
|
||||||
|
.orchestrator-allowlists {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orchestrator-section-head,
|
||||||
|
.orchestrator-health {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.providers-page {
|
.providers-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -577,6 +783,51 @@ body,
|
|||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-service-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--color-border-input);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-card legend {
|
||||||
|
padding: 0 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-toggle {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--color-text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-toggle input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-service-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-llm-hint {
|
||||||
|
margin: -2px 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.form-span-all {
|
.form-span-all {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -1080,6 +1331,60 @@ body,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.asset-image-make-note,
|
||||||
|
.asset-no-llm {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(132, 110, 255, .1);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-purpose-fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-purpose-fieldset legend {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-purpose-fieldset > p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-purpose-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-purpose-grid label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: rgba(255, 255, 255, .035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-purpose-grid small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.asset-plugin-footer {
|
.asset-plugin-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -208,6 +208,12 @@ export type AssetPluginConfig = {
|
|||||||
llmProviderId: string | null;
|
llmProviderId: string | null;
|
||||||
llmModel: string | null;
|
llmModel: string | null;
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
|
purposes?: Record<string, boolean>;
|
||||||
|
purposeCatalog?: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
presetId: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssetGatewayConfig = {
|
export type AssetGatewayConfig = {
|
||||||
@@ -619,4 +625,39 @@ export type MindSearchConfig = {
|
|||||||
mode: 'off' | 'shadow' | 'assist';
|
mode: 'off' | 'shadow' | 'assist';
|
||||||
providers: { searxng: boolean; github: boolean; reader: boolean };
|
providers: { searxng: boolean; github: boolean; reader: boolean };
|
||||||
settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number };
|
settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number };
|
||||||
|
services: MindSearchService[];
|
||||||
|
routes: MindSearchRoutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MindSearchService = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: 'provider' | 'reader' | 'orchestrator';
|
||||||
|
adapter: 'searxng' | 'github' | 'reader' | 'research-http';
|
||||||
|
capabilities: string[];
|
||||||
|
endpoint: string;
|
||||||
|
healthPath: string;
|
||||||
|
enabled: boolean;
|
||||||
|
timeoutMs: number;
|
||||||
|
priority: number;
|
||||||
|
llmProviderKeyId?: string;
|
||||||
|
llmModel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MindSearchRoutes = {
|
||||||
|
web: string;
|
||||||
|
news: string;
|
||||||
|
code: string;
|
||||||
|
read: string;
|
||||||
|
research: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MindSearchServiceTestResult = {
|
||||||
|
ok: boolean;
|
||||||
|
serviceId: string;
|
||||||
|
adapter: MindSearchService['adapter'];
|
||||||
|
status: number | null;
|
||||||
|
latencyMs: number;
|
||||||
|
resultCount?: number | null;
|
||||||
|
message: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
export type OrchestratorMode = 'off' | 'shadow' | 'canary' | 'active';
|
||||||
|
|
||||||
|
export type OrchestratorConfig = {
|
||||||
|
mode: OrchestratorMode;
|
||||||
|
primaryEngine: string;
|
||||||
|
fallbackEngine: string;
|
||||||
|
serviceUrl: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
rolloutPercent: number;
|
||||||
|
userAllowlist: string[];
|
||||||
|
workflowAllowlist: string[];
|
||||||
|
fallbackToNative: boolean;
|
||||||
|
requireHealthy: boolean;
|
||||||
|
executionEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorRuntime = {
|
||||||
|
killSwitch: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
effective: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
executesLangGraph: boolean;
|
||||||
|
plansLangGraph: boolean;
|
||||||
|
shadowsLangGraph: boolean;
|
||||||
|
executionHandoff: {
|
||||||
|
implemented: boolean;
|
||||||
|
requested: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
environmentGate: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorConfigState = {
|
||||||
|
config: OrchestratorConfig;
|
||||||
|
configVersion: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
updatedAt: number | null;
|
||||||
|
source: string;
|
||||||
|
runtime: OrchestratorRuntime;
|
||||||
|
engines: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
configured: boolean;
|
||||||
|
capabilities: string[];
|
||||||
|
}>;
|
||||||
|
executors: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
enabled: boolean;
|
||||||
|
dispatchImplemented: boolean;
|
||||||
|
status: string;
|
||||||
|
capabilities: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorServiceHealth = {
|
||||||
|
checkedAt: number;
|
||||||
|
ok: boolean;
|
||||||
|
status: string;
|
||||||
|
latencyMs: number;
|
||||||
|
httpStatus: number | null;
|
||||||
|
details: {
|
||||||
|
service: string | null;
|
||||||
|
checkpoint: { kind?: string; durable?: boolean } | null;
|
||||||
|
execution: string | null;
|
||||||
|
executorGateway: {
|
||||||
|
dispatchImplemented: boolean;
|
||||||
|
executionEnabled: boolean;
|
||||||
|
store: { kind: string | null; durable: boolean } | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorRuntimeState = OrchestratorConfigState & {
|
||||||
|
serviceHealth: OrchestratorServiceHealth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorShadowRun = {
|
||||||
|
eventId: string;
|
||||||
|
runId: string;
|
||||||
|
requestId: string;
|
||||||
|
userId: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
nativeStatus: string;
|
||||||
|
nativeAttempts: number;
|
||||||
|
shadowStatus: 'succeeded' | 'failed';
|
||||||
|
engine: string;
|
||||||
|
configVersion: number | null;
|
||||||
|
phase: string | null;
|
||||||
|
taskType: string | null;
|
||||||
|
synthetic: boolean;
|
||||||
|
executorAdapter: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
error: { code: string; message: string } | null;
|
||||||
|
observedAt: number;
|
||||||
|
nativeCompletedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorShadowRunList = {
|
||||||
|
generatedAt: number;
|
||||||
|
window: { hours: number; from: number };
|
||||||
|
metrics: {
|
||||||
|
observations: number;
|
||||||
|
successes: number;
|
||||||
|
failures: number;
|
||||||
|
successRate: number | null;
|
||||||
|
failureRate: number | null;
|
||||||
|
latencyP50Ms: number | null;
|
||||||
|
latencyP95Ms: number | null;
|
||||||
|
nativeSucceeded: number;
|
||||||
|
nativeFailed: number;
|
||||||
|
lastObservedAt: number | null;
|
||||||
|
sampled: boolean;
|
||||||
|
};
|
||||||
|
runs: OrchestratorShadowRun[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorExecutionPlanList = {
|
||||||
|
generatedAt: number;
|
||||||
|
window: { hours: number; from: number };
|
||||||
|
metrics: {
|
||||||
|
decisions: number;
|
||||||
|
candidateSelections: number;
|
||||||
|
candidateSelectionRate: number | null;
|
||||||
|
nativeSelections: number;
|
||||||
|
nativeSucceeded: number;
|
||||||
|
nativeFailed: number;
|
||||||
|
nativeSettledRate: number | null;
|
||||||
|
handoffAllowed: number;
|
||||||
|
distinctSessions: number;
|
||||||
|
lastPlannedAt: number | null;
|
||||||
|
candidateReasons: Array<{ value: string; count: number }>;
|
||||||
|
taskTypes: Array<{ value: string; count: number }>;
|
||||||
|
sampled: boolean;
|
||||||
|
};
|
||||||
|
plans: Array<{
|
||||||
|
eventId: string;
|
||||||
|
runId: string;
|
||||||
|
requestId: string;
|
||||||
|
userId: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
nativeStatus: string;
|
||||||
|
nativeAttempts: number;
|
||||||
|
mode: string | null;
|
||||||
|
candidateEngine: string;
|
||||||
|
effectiveEngine: string;
|
||||||
|
fallbackEngine: string;
|
||||||
|
reason: string | null;
|
||||||
|
candidateReason: string | null;
|
||||||
|
configVersion: number | null;
|
||||||
|
bucket: number | null;
|
||||||
|
taskType: string | null;
|
||||||
|
dryRun: boolean;
|
||||||
|
handoffAllowed: boolean;
|
||||||
|
plannedAt: number;
|
||||||
|
nativeCompletedAt: number | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorCanaryReadinessCheck = {
|
||||||
|
id: string;
|
||||||
|
passed: boolean;
|
||||||
|
actual: string | number | boolean | null;
|
||||||
|
target: string | number | boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorCanaryReadiness = {
|
||||||
|
generatedAt: number;
|
||||||
|
ready: boolean;
|
||||||
|
recommendation: 'keep_shadow' | 'manual_canary_review';
|
||||||
|
window: { hours: number; from: number };
|
||||||
|
thresholds: {
|
||||||
|
hours: number;
|
||||||
|
minObservations: number;
|
||||||
|
minSuccessRate: number;
|
||||||
|
maxP95LatencyMs: number;
|
||||||
|
minLatencyCoverageRate: number;
|
||||||
|
minNativeSettledRate: number;
|
||||||
|
minDistinctSessions: number;
|
||||||
|
maxHoursSinceLastObservation: number;
|
||||||
|
};
|
||||||
|
samples: {
|
||||||
|
totalObservations: number;
|
||||||
|
eligibleObservations: number;
|
||||||
|
excludedSynthetic: number;
|
||||||
|
successes: number;
|
||||||
|
failures: number;
|
||||||
|
successRate: number | null;
|
||||||
|
latencyCoverageRate: number | null;
|
||||||
|
latencyP95Ms: number | null;
|
||||||
|
nativeSettledRate: number | null;
|
||||||
|
distinctSessions: number;
|
||||||
|
lastObservedAt: number | null;
|
||||||
|
hoursSinceLastObservation: number | null;
|
||||||
|
sampled: boolean;
|
||||||
|
};
|
||||||
|
service: {
|
||||||
|
status: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
checkpointKind: string | null;
|
||||||
|
checkpointDurable: boolean | null;
|
||||||
|
executorJobStoreKind: string | null;
|
||||||
|
executorJobStoreDurable: boolean | null;
|
||||||
|
execution: string | null;
|
||||||
|
};
|
||||||
|
checks: OrchestratorCanaryReadinessCheck[];
|
||||||
|
blockers: string[];
|
||||||
|
failureCodes: Array<{ code: string; count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrchestratorShadowRunDetail = {
|
||||||
|
native: {
|
||||||
|
runId: string;
|
||||||
|
requestId: string;
|
||||||
|
userId: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
completedAt: number | null;
|
||||||
|
};
|
||||||
|
shadow: OrchestratorShadowRun | null;
|
||||||
|
remote: {
|
||||||
|
available: boolean;
|
||||||
|
state: Record<string, unknown> | null;
|
||||||
|
events: Array<Record<string, unknown>>;
|
||||||
|
error: { code: string; message: string } | null;
|
||||||
|
executorJob: {
|
||||||
|
available: boolean;
|
||||||
|
state: Record<string, unknown> | null;
|
||||||
|
events: Array<Record<string, unknown>>;
|
||||||
|
error: { code: string; message: string } | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user