Compare commits

..

28 Commits

Author SHA1 Message Date
john 9933bfb89c merge: fix billing usage summary and user balance display 2026-07-31 17:30:27 +08:00
john aca1e58f53 fix(billing): show user balance and correct all-records summary scope
When the usage overview has no date filter, show only all-time totals instead
of a misleading 30-day range block; surface current balance in user analysis.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:30:14 +08:00
john 2550b3b323 feat(billing): add usage analytics charts and summary totals
Add daily usage trend charts with multi-metric toggles, overview vs user
analysis views, date-range filtering, and usage summary API for interval
and all-time totals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 11:28:47 +08:00
john 2a1c221bb3 feat(billing): show user display name in usage records
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 09:51:38 +08:00
john 65a6d7254b merge: gate memind-lib DeepSeek catalog on deepseek-v4 models 2026-07-26 10:13:45 +08:00
john 16b1a90edf fix(release): gate memind-lib DeepSeek catalog on deepseek-v4 models
Refuse memindadm production releases when bundled llm-providers.mjs still
carries the legacy deepseek-chat catalog instead of deepseek-v4-pro/flash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:13:34 +08:00
john afc667a9ce merge: 合入任务编排管理页
将 codex/orchestrator-shadow-ui 的 Orchestrator 控制台与路由测试合入主线。
2026-07-25 10:10:14 +08:00
john 10e24be243 test: cover orchestrator admin boundary 2026-07-25 07:20:25 +08:00
john 2596c8f2c6 feat: add orchestrator controls to memindadm 2026-07-25 07:12:53 +08:00
john dd3fed5b14 feat: add Rybbit admin SSO entry 2026-07-24 17:12:31 +08:00
tkmind b009f31aae merge: production-ready Deep Search admin
Merge the verified MindSearch admin configuration and hardened shared-source release gates.
2026-07-24 02:18:48 +00:00
john 4d7bcbe948 fix: harden admin production source gates 2026-07-24 09:53:06 +08:00
john 1b5bbdf467 fix: expose new built-in search services 2026-07-23 22:51:44 +08:00
john 6977f9fc24 feat: select deep search llm in admin 2026-07-23 22:46:14 +08:00
john ce8247743e feat: register local deep search service 2026-07-23 22:27:11 +08:00
john 04859defeb feat: manage pluggable search services 2026-07-23 21:38:41 +08:00
john bacc31c83a Merge branch 'release/episodic-memory-admin-20260722-r3' 2026-07-23 18:23:12 +08:00
john 693fc8a712 Merge remote-tracking branch 'origin/main' 2026-07-23 18:23:09 +08:00
john 00a4855715 fix: wire admin recharge WeChat notifications 2026-07-23 18:14:10 +08:00
tkmind 4f708d10bd Merge pull request 'fix: 等待后台服务真正就绪' (#3) from codex/fix-admin-startup-readiness into main 2026-07-22 05:22:37 +00:00
john 65970f383d fix: wait for admin startup readiness 2026-07-22 13:19:29 +08:00
tkmind f6624936d6 Merge pull request 'fix: 补齐后台共享模块图像运行依赖' (#2) from codex/fix-admin-sharp-runtime into main 2026-07-22 05:17:17 +00:00
john c0d74b9486 fix: include sharp in admin runtime 2026-07-22 13:15:50 +08:00
tkmind 75057bd285 Merge pull request 'feat: 增加历史会话召回灰度控制' (#1) from codex/episodic-memory-rollout-admin into main
Admin build and release gate passed.
2026-07-22 04:59:45 +00:00
john 6e874c7a8e feat: add episodic recall rollout controls 2026-07-22 12:36:30 +08:00
john 790c5794e9 feat: add image_make controls to admin 2026-07-19 21:28:00 +08:00
john 75b7c07a4d fix: keep skill sync on Portal workspace root 2026-07-17 13:58:55 +08:00
john d864bcd6fb fix: package Excel skill controls in admin runtime 2026-07-17 13:54:25 +08:00
26 changed files with 4264 additions and 105 deletions
-1
View File
@@ -1 +0,0 @@
26747
-1
View File
@@ -1 +0,0 @@
25645
+5
View File
@@ -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
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 帖子预览链接
# VITE_PLAZA_BASE=https://plaza.tkmind.cn
+1
View File
@@ -8,4 +8,5 @@ dist-ssr/
memind-lib/
.vite/
*.log
*.pid
logs/
+1014 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,7 @@
"local_restart": "bash scripts/local_restart.sh",
"pro_restart": "bash scripts/pro_restart.sh",
"build": "vite build",
"test:orchestrator": "node --test server/orchestrator-routes.test.mjs",
"preview": "node scripts/preview.mjs",
"dev:preview": "vite preview"
},
@@ -28,7 +29,9 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.10.1",
"redis": "^4.7.1",
"sharp": "0.35.2",
"undici": "^6.26.0"
},
"devDependencies": {
+78 -1
View File
@@ -85,14 +85,27 @@ need_cmd scp
need_cmd rsync
need_cmd tar
need_cmd shasum
need_cmd node
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
echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2
exit 1
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}" ]] || {
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
exit 1
@@ -110,6 +123,12 @@ if [[ "${SKIP_BUILD}" -ne 1 ]]; then
)
fi
say "验证共享模块图像运行依赖"
(
cd "${ROOT}"
node --input-type=module -e "const { default: sharp } = await import('sharp'); await sharp({ create: { width: 1, height: 1, channels: 4, background: '#000000' } }).png().toBuffer();"
)
[[ -d "${ROOT}/dist" ]] || {
echo "缺少 dist/,请先本地构建或去掉 --skip-build" >&2
exit 1
@@ -120,6 +139,24 @@ fi
exit 1
}
git -C "${MEMIND_SRC}" fetch origin main --quiet
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"
rm -rf "${MEMIND_LIB}"
mkdir -p "${MEMIND_LIB}"
@@ -133,10 +170,48 @@ rsync -a \
--include '*.js' \
--exclude '*' \
"${MEMIND_SRC}/" "${MEMIND_LIB}/"
if [[ -d "${MEMIND_SRC}/skills" ]]; then
say "打包 Memind 平台技能目录到 memind-lib"
mkdir -p "${MEMIND_LIB}/skills"
rsync -a \
--exclude '.DS_Store' \
--exclude '__pycache__/' \
"${MEMIND_SRC}/skills/" "${MEMIND_LIB}/skills/"
fi
[[ -f "${MEMIND_LIB}/user-auth.mjs" ]] || {
echo "memind-lib 缺少 user-auth.mjs" >&2
exit 1
}
[[ -f "${MEMIND_LIB}/skills/excel-analyst/SKILL.md" ]] || {
echo "memind-lib 缺少 Excel Analyst 技能" >&2
exit 1
}
grep -q 'EXCEL_ANALYST_SKILL_NAME' "${MEMIND_LIB}/skills-registry.mjs" || {
echo "memind-lib 缺少 Excel Analyst 技能授权映射" >&2
exit 1
}
grep -q 'tkmind-excel' "${MEMIND_LIB}/capabilities.mjs" || {
echo "memind-lib 缺少 Excel Analyst runtime 扩展映射" >&2
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
say "发布确认"
@@ -158,6 +233,8 @@ say "生成发布清单"
echo "root=${ROOT}"
echo "git_head=$(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)"
echo "memind_git_head=${MEMIND_GIT_HEAD}"
echo "memind_git_branch=${MEMIND_GIT_BRANCH}"
echo "git_status_begin"
git -C "${ROOT}" status --short --branch || true
echo "git_status_end"
+15 -4
View File
@@ -34,6 +34,19 @@ export ADM_WEB_HOST="${WEB_HOST}"
export ADM_PORT="${PORT}"
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
echo "${ROOT}/dist 不存在,请先构建或 rsync dist" >&2
exit 1
@@ -61,9 +74,8 @@ sleep 1
echo "==> 启动 Admin API @ ${API_HOST}:${API_PORT}..."
nohup node server/index.mjs >>"${API_LOG}" 2>&1 &
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
tail -n 40 "${API_LOG}" >&2 || true
exit 1
@@ -72,11 +84,10 @@ fi
echo "==> 启动 vite preview @ ${WEB_HOST}:${PORT} (base=${BASE_PATH})..."
nohup npx vite preview --config scripts/vite-preview.config.mjs >>"${LOG}" 2>&1 &
echo $! >"${PID_FILE}"
sleep 2
preview_url="http://127.0.0.1:${PORT}${BASE_PATH}"
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}"
curl -sf "${preview_url}" >/dev/null && echo " 首页 OK"
echo " /auth 反代 OK"
+122 -1
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { listUsagePaged, listLedgerPaged } from './pagination.mjs';
import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs';
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -100,6 +100,8 @@ export function createAdminApp(services) {
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
orchestratorConfigService,
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
@@ -232,6 +234,21 @@ export function createAdminApp(services) {
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) => {
const result = await userAuth.listUsers({
page: Number(req.query.page) || 1,
@@ -290,7 +307,29 @@ export function createAdminApp(services) {
res.json({ user: result.user });
});
async function handleUsageStats(req, res) {
try {
const result = await getUsageStats(pool, req.query);
res.json(result);
} catch (err) {
if (err?.code === 'INVALID_TIME_RANGE') {
return res.status(400).json({ message: '无效的时间范围' });
}
throw err;
}
}
adminApi.get('/usage/summary', requireAdmin, async (req, res) => {
const result = await getUsageSummary(pool, req.query);
res.json(result);
});
adminApi.get('/usage/stats', requireAdmin, handleUsageStats);
adminApi.get('/usage', requireAdmin, async (req, res) => {
if (req.query.stats === '1' || req.query.stats === 'true') {
return handleUsageStats(req, res);
}
const result = await listUsagePaged(pool, req.query);
res.json(result);
});
@@ -382,6 +421,14 @@ export function createAdminApp(services) {
res.json(result);
});
adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService?.getRuntimeState) {
return res.status(503).json({ message: 'Memory V2 配置服务未启用' });
}
const result = await memoryV2ConfigService.getRuntimeState();
return res.json(result);
});
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.getAdminConfig());
@@ -401,6 +448,80 @@ export function createAdminApp(services) {
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
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) => {
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
+25
View File
@@ -18,6 +18,7 @@ export async function bootstrapAdminServices() {
const createLlmProviderService = await loadCreateLlmProviderService();
const { createWechatAdminService } = await importMemind('wechat-admin.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 { createPlazaInteractionService } = await importMemind('plaza-interactions.mjs');
const { createPlazaOpsService } = await importMemind('plaza-ops.mjs');
@@ -30,6 +31,12 @@ export async function bootstrapAdminServices() {
} = await importMemind('mindspace-config.mjs');
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-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 { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
@@ -98,6 +105,14 @@ export async function bootstrapAdminServices() {
});
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
await mindSearchConfigService.ensureSchema();
const orchestratorConfigService = createOrchestratorAdminConfigService(pool, {
env: process.env,
});
await orchestratorConfigService.ensureSchema();
const orchestratorObservabilityService = createOrchestratorObservabilityService({
pool,
configService: orchestratorConfigService,
});
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
env: process.env,
@@ -119,6 +134,14 @@ export async function bootstrapAdminServices() {
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, {
config: wechatMpConfig,
scheduleEnabled: process.env.H5_SCHEDULE_ENABLED === '1',
@@ -156,6 +179,8 @@ export async function bootstrapAdminServices() {
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
orchestratorConfigService,
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
+4
View File
@@ -106,6 +106,8 @@ ready
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
orchestratorConfigService,
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
@@ -132,6 +134,8 @@ ready
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
orchestratorConfigService,
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
+176
View File
@@ -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);
});
+128 -3
View File
@@ -17,21 +17,145 @@ export function paginateArray(items, query, defaultPageSize = 20) {
};
}
const MS_THRESHOLD = 1_000_000_000_000;
function parseUsageTimeFilters(query) {
const startAt = Number(query.startAt);
const endAt = Number(query.endAt);
const hasStart = Number.isFinite(startAt) && startAt > 0;
const hasEnd = Number.isFinite(endAt) && endAt > 0;
if (!hasStart || !hasEnd || startAt > endAt) {
return null;
}
// Frontend sends unix seconds; h5_usage_records.created_at is stored in milliseconds.
const startAtMs = startAt < MS_THRESHOLD ? startAt * 1000 : startAt;
const endAtMs = endAt < MS_THRESHOLD ? endAt * 1000 + 999 : endAt;
return { startAt, endAt, startAtMs, endAtMs };
}
function formatSqlDay(value) {
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return String(value).slice(0, 10);
}
export async function getUsageTotals(pool, { userId = null, startAtMs = null, endAtMs = null } = {}) {
const params = [];
const clauses = [];
if (userId) {
clauses.push('r.user_id = ?');
params.push(userId);
}
if (startAtMs != null && endAtMs != null) {
clauses.push('r.created_at >= ?', 'r.created_at <= ?');
params.push(startAtMs, endAtMs);
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [[row]] = await pool.query(
`SELECT COUNT(*) AS count,
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
COALESCE(SUM(r.cost_cents), 0) AS cost_cents
FROM h5_usage_records r
${where}`,
params,
);
return {
count: Number(row.count),
inputTokens: Number(row.input_tokens),
outputTokens: Number(row.output_tokens),
costCents: Number(row.cost_cents),
};
}
export async function getUsageSummary(pool, query) {
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
const range = parseUsageTimeFilters(query);
const allTime = await getUsageTotals(pool, { userId });
if (!range) {
return { range: null, allTime };
}
const rangeTotals = await getUsageTotals(pool, {
userId,
startAtMs: range.startAtMs,
endAtMs: range.endAtMs,
});
return {
range: {
...rangeTotals,
startAt: range.startAt,
endAt: range.endAt,
},
allTime,
};
}
export async function getUsageStats(pool, query) {
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
const range = parseUsageTimeFilters(query);
if (!range) {
const err = new Error('INVALID_TIME_RANGE');
err.code = 'INVALID_TIME_RANGE';
throw err;
}
const { startAt, endAt, startAtMs, endAtMs } = range;
const params = [startAtMs, endAtMs];
const clauses = ['r.created_at >= ?', 'r.created_at <= ?'];
if (userId) {
clauses.push('r.user_id = ?');
params.push(userId);
}
const where = `WHERE ${clauses.join(' AND ')}`;
const [rows] = await pool.query(
`SELECT DATE(FROM_UNIXTIME(r.created_at / 1000)) AS day,
COUNT(*) AS count,
COALESCE(SUM(r.input_tokens), 0) AS input_tokens,
COALESCE(SUM(r.output_tokens), 0) AS output_tokens,
COALESCE(SUM(r.cost_cents), 0) AS cost_cents
FROM h5_usage_records r
${where}
GROUP BY day
ORDER BY day ASC`,
params,
);
return {
buckets: rows.map((row) => ({
day: formatSqlDay(row.day),
count: Number(row.count),
inputTokens: Number(row.input_tokens),
outputTokens: Number(row.output_tokens),
costCents: Number(row.cost_cents),
})),
startAt,
endAt,
};
}
export async function listUsagePaged(pool, query) {
const { page, pageSize, offset } = parsePageQuery(query, 20);
const userId = typeof query.userId === 'string' && query.userId ? query.userId : null;
const range = parseUsageTimeFilters(query);
const params = [];
let where = '';
const clauses = [];
if (userId) {
where = 'WHERE r.user_id = ?';
clauses.push('r.user_id = ?');
params.push(userId);
}
if (range) {
clauses.push('r.created_at >= ?', 'r.created_at <= ?');
params.push(range.startAtMs, range.endAtMs);
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
params,
);
const [rows] = await pool.query(
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
`SELECT r.id, r.user_id, u.username, u.display_name, r.agent_session_id, r.request_id,
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
FROM h5_usage_records r
JOIN h5_users u ON u.id = r.user_id
@@ -45,6 +169,7 @@ export async function listUsagePaged(pool, query) {
id: Number(row.id),
userId: row.user_id,
username: row.username,
displayName: row.display_name,
agentSessionId: row.agent_session_id,
requestId: row.request_id,
inputTokens: Number(row.input_tokens),
+3
View File
@@ -19,6 +19,7 @@ import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
import { OrchestratorPage } from './admin/pages/OrchestratorPage';
import { defaultHomePath } from './lib/routes';
import { OpsLayout } from './ops/components/OpsLayout';
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="mindsearch" element={<MindSearchPage />} />
<Route path="skill-runtime" element={<SkillRuntimePage />} />
<Route path="orchestrator" element={<OrchestratorPage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="asset-gateway" element={<AssetGatewayPage />} />
@@ -175,6 +177,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/memory-v2')
|| pathname.startsWith('/skill-runtime')
|| pathname.startsWith('/orchestrator')
|| pathname.startsWith('/providers')
|| pathname.startsWith('/wechat')
|| pathname.startsWith('/asset-gateway')
+1
View File
@@ -32,6 +32,7 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/mindsearch', label: 'MindSearch' },
{ to: '/skill-runtime', label: 'Skill Runtime' },
{ to: '/orchestrator', label: '任务编排' },
{ to: '/system-tests', label: '系统测试验证' },
{ to: '/capabilities', label: '能力' },
{ to: '/skills', label: '技能' },
+28 -7
View File
@@ -1,5 +1,5 @@
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 = {
enabled: false,
@@ -16,7 +16,8 @@ export function AnalyticsConfigPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = 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 () => {
setLoading(true);
@@ -52,23 +53,43 @@ export function AnalyticsConfigPage() {
};
const openUmami = async () => {
setOpening(true);
setOpeningUmami(true);
setError(null);
try {
window.location.assign(await getUmamiSsoUrl());
} catch (err) {
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">
<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 </p></div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
<section className="admin-card">
<h2> Umami</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>
<h2>Rybbit </h2>
<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}>
<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>
+43 -2
View File
@@ -12,6 +12,7 @@ type PluginDraft = {
provider: string;
llmProviderKeyId: string;
llmModel: string;
purposes: Record<string, boolean>;
};
function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
@@ -20,6 +21,7 @@ function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
provider: plugin.provider ?? '',
llmProviderKeyId: plugin.llmProviderKeyId ?? '',
llmModel: plugin.llmModel ?? '',
purposes: { ...(plugin.purposes ?? {}) },
};
}
@@ -61,6 +63,16 @@ export function AssetGatewayPage() {
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) => {
setBusy('global'); setMessage(null); setError(null);
try {
@@ -81,6 +93,7 @@ export function AssetGatewayPage() {
provider: draft.provider || null,
llmProviderKeyId: draft.llmProviderKeyId || null,
llmModel: draft.llmModel || null,
purposes: draft.purposes,
});
setConfig(result.config);
setDrafts(Object.fromEntries(result.config.plugins.map((item) => [item.pluginId, draftFromPlugin(item)])));
@@ -126,11 +139,21 @@ export function AssetGatewayPage() {
</label>
</div>
<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>
{plugin.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
</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}>
<option value="">使 LLM</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>)}
</select></label>
</> : <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 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>;
+582 -60
View File
@@ -1,8 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
cancelUserSubscription,
createSubscriptionPlan,
deleteSubscriptionPlan,
getAdminUsageStats,
getAdminUsageSummary,
grantUserSubscription,
listAdminLedger,
listAdminSubscriptions,
@@ -13,8 +16,19 @@ import {
updateSubscriptionPlan,
} from '../../api/client';
import type { PagedResult } from '../../api/client';
import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord } from '../../types';
import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord, UsageStatsResult, UsageSummaryResult, UsageTotals } from '../../types';
import { Pagination } from '../../components/Pagination';
import {
dateRangeToUnix,
DEFAULT_CHART_METRICS,
METRIC_CONFIG,
METRIC_OPTIONS,
presetDateRange,
toggleChartMetric,
UsageTrendChart,
type UsageChartMetric,
type UsageChartType,
} from '../../components/UsageTrendChart';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatTime, formatYuan } from '../utils/format';
@@ -111,6 +125,22 @@ const TABS: { key: TabKey; label: string }[] = [
{ key: 'ledger', label: '资金流水' },
];
const TAB_PATHS: Record<TabKey, string> = {
subscriptions: '/billing',
recharge: '/billing/recharge',
usage: '/billing/usage',
ledger: '/billing/ledger',
};
function tabFromPath(pathname: string): TabKey {
const suffix = pathname.replace(/^\/billing\/?/, '');
if (suffix === 'usage' || suffix.startsWith('usage/')) return 'usage';
if (suffix === 'recharge') return 'recharge';
if (suffix === 'ledger') return 'ledger';
if (suffix === 'subscriptions') return 'subscriptions';
return 'subscriptions';
}
const PAGE_SIZE = 20;
function RechargeTab() {
@@ -161,21 +191,414 @@ function RechargeTab() {
);
}
function UsageTab() {
const { users } = useAdminUsers();
function UsageRecordsTable({
result,
onPage,
}: {
result: PagedResult<UsageRecord>;
onPage: (page: number) => void;
}) {
return (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>
<span>{row.displayName || row.username}</span>
<span className="muted"> @{row.username}</span>
</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={result.page}
totalPages={result.totalPages}
total={result.total}
pageSize={result.pageSize}
onChange={onPage}
/>
</>
);
}
function UsageTotalsGrid({ totals }: { totals: UsageTotals }) {
const totalTokens = totals.inputTokens + totals.outputTokens;
return (
<div className="admin-stat-grid usage-summary-grid">
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">{totals.count.toLocaleString()}</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">Token </div>
<div className="admin-stat-value">{totalTokens.toLocaleString()}</div>
<div className="muted usage-summary-sub">
{totals.inputTokens.toLocaleString()} / {totals.outputTokens.toLocaleString()}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label"></div>
<div className="admin-stat-value">¥{formatYuan(totals.costCents)}</div>
</div>
</div>
);
}
function UsageSummaryPanel({
summary,
rangeLabel,
allTimeLabel = '累计(不限区间)',
loading,
}: {
summary: UsageSummaryResult | null;
rangeLabel: string;
allTimeLabel?: string;
loading?: boolean;
}) {
if (loading && !summary) {
return <p className="muted usage-summary-loading"></p>;
}
if (!summary) return null;
return (
<div className="usage-summary-panel">
{summary.range && (
<section className="usage-summary-block">
<h4 className="usage-summary-title">{rangeLabel}</h4>
<UsageTotalsGrid totals={summary.range} />
</section>
)}
<section className="usage-summary-block">
<h4 className="usage-summary-title">{allTimeLabel}</h4>
<UsageTotalsGrid totals={summary.allTime} />
</section>
</div>
);
}
function UsageChartPanel({
stats,
chartMetrics,
chartType,
onMetricsChange,
onChartTypeChange,
title = '用量趋势',
hint,
}: {
stats: UsageStatsResult;
chartMetrics: UsageChartMetric[];
chartType: UsageChartType;
onMetricsChange: (metrics: UsageChartMetric[]) => void;
onChartTypeChange: (type: UsageChartType) => void;
title?: string;
hint?: string;
}) {
return (
<div className="usage-chart-panel">
<div className="usage-chart-head">
<div>
<h3>{title}</h3>
{hint && <p className="muted usage-chart-hint">{hint}</p>}
</div>
<div className="usage-chart-controls">
<div className="usage-metric-checks" role="group" aria-label="图表指标">
{METRIC_OPTIONS.map(({ key, label }) => {
const checked = chartMetrics.includes(key);
const color = METRIC_CONFIG[key].color;
return (
<label key={key} className={`usage-metric-check${checked ? ' checked' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => onMetricsChange(toggleChartMetric(chartMetrics, key))}
/>
<span className="usage-metric-check-dot" style={{ background: color }} />
{label}
</label>
);
})}
</div>
<div className="usage-chart-type-toggle" role="group" aria-label="图表类型">
<button
type="button"
className={`usage-chart-type-btn${chartType === 'bar' ? ' active' : ''}`}
onClick={() => onChartTypeChange('bar')}
>
</button>
<button
type="button"
className={`usage-chart-type-btn${chartType === 'line' ? ' active' : ''}`}
onClick={() => onChartTypeChange('line')}
>
线
</button>
</div>
</div>
</div>
<UsageTrendChart
buckets={stats.buckets}
startAt={stats.startAt}
endAt={stats.endAt}
metrics={chartMetrics}
chartType={chartType}
/>
</div>
);
}
type UsageView = 'overview' | 'query';
function UsageOverviewPanel() {
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [stats, setStats] = useState<UsageStatsResult | null>(null);
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
const [loadingTable, setLoadingTable] = useState(false);
const [loadingChart, setLoadingChart] = useState(false);
const [loadingSummary, setLoadingSummary] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
const [chartType, setChartType] = useState<UsageChartType>('bar');
const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate);
const loadTable = useCallback(async (page: number, start: string, end: string) => {
if (start && end && start > end) {
setError('请选择有效的时间范围');
return;
}
setLoadingTable(true);
setError(null);
try {
const params: { page: number; pageSize: number; startAt?: number; endAt?: number } = {
page,
pageSize: PAGE_SIZE,
};
if (start && end) {
const range = dateRangeToUnix(start, end);
params.startAt = range.startAt;
params.endAt = range.endAt;
}
const data = await listAdminUsage(params);
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoadingTable(false);
}
}, []);
const loadChart = useCallback(async (start: string, end: string) => {
if (start && end && start > end) return;
setLoadingChart(true);
setError(null);
try {
const chartRange = start && end
? { startDate: start, endDate: end }
: presetDateRange(30);
const { startAt, endAt } = dateRangeToUnix(chartRange.startDate, chartRange.endDate);
const statsData = await getAdminUsageStats({ startAt, endAt });
setStats(statsData);
} catch (err) {
setError(err instanceof Error ? err.message : '图表加载失败');
} finally {
setLoadingChart(false);
}
}, []);
const loadSummary = useCallback(async (start: string, end: string) => {
if (start && end && start > end) return;
setLoadingSummary(true);
setError(null);
try {
const data = start && end
? await getAdminUsageSummary(dateRangeToUnix(start, end))
: await getAdminUsageSummary();
setSummary(data);
} catch (err) {
setError(err instanceof Error ? err.message : '汇总加载失败');
} finally {
setLoadingSummary(false);
}
}, []);
useEffect(() => {
void loadTable(1, startDate, endDate);
}, [startDate, endDate, loadTable]);
useEffect(() => {
void loadChart(startDate, endDate);
}, [startDate, endDate, loadChart]);
useEffect(() => {
void loadSummary(startDate, endDate);
}, [startDate, endDate, loadSummary]);
const applyPreset = (days: number) => {
const range = presetDateRange(days);
setStartDate(range.startDate);
setEndDate(range.endDate);
};
const clearDateFilter = () => {
setStartDate('');
setEndDate('');
};
const handlePage = (page: number) => void loadTable(page, startDate, endDate);
const chartHint = hasDateFilter
? `${startDate}${endDate} 全平台用量(列表与图表同步筛选)`
: '近 30 天全平台用量(列表展示全部记录;设置日期后列表与图表同步筛选)';
const rangeSummaryLabel = hasDateFilter
? `区间汇总(${startDate}${endDate}`
: '';
return (
<>
{error && <p className="banner banner-error">{error}</p>}
<div className="usage-overview-filter">
<span className="muted usage-overview-chart-label"></span>
<div className="usage-date-range">
<input
type="date"
className="billing-filter-select usage-date-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<span className="muted usage-date-sep"></span>
<input
type="date"
className="billing-filter-select usage-date-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="usage-preset-btns">
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(7)}>7</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(30)}>30</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" onClick={() => applyPreset(90)}>90</button>
<button
type="button"
className={`admin-btn-secondary usage-preset-btn${!hasDateFilter ? ' active' : ''}`}
onClick={clearDateFilter}
>
</button>
</div>
</div>
<UsageSummaryPanel
summary={summary}
rangeLabel={rangeSummaryLabel}
allTimeLabel="累计汇总(不限区间 · 全平台)"
loading={loadingSummary}
/>
{loadingChart && !stats && <p className="muted"></p>}
{!loadingChart && stats && (
<UsageChartPanel
stats={stats}
chartMetrics={chartMetrics}
chartType={chartType}
onMetricsChange={setChartMetrics}
onChartTypeChange={setChartType}
title="用量统计"
hint={chartHint}
/>
)}
{loadingTable && !result && <p className="muted"></p>}
{!loadingTable && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
) : (
<UsageRecordsTable result={result} onPage={handlePage} />
)
)}
</>
);
}
function UsageQueryPanel() {
const { users } = useAdminUsers();
const defaultRange = presetDateRange(30);
const [result, setResult] = useState<PagedResult<UsageRecord> | null>(null);
const [stats, setStats] = useState<UsageStatsResult | null>(null);
const [summary, setSummary] = useState<UsageSummaryResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filterUserId, setFilterUserId] = useState('');
const [pendingUserId, setPendingUserId] = useState('');
const [userId, setUserId] = useState('');
const [startDate, setStartDate] = useState(defaultRange.startDate);
const [endDate, setEndDate] = useState(defaultRange.endDate);
const [chartMetrics, setChartMetrics] = useState<UsageChartMetric[]>(DEFAULT_CHART_METRICS);
const [chartType, setChartType] = useState<UsageChartType>('bar');
const load = useCallback(async (p: number, userId: string) => {
const hasDateFilter = Boolean(startDate && endDate && startDate <= endDate);
const load = useCallback(async (p: number, uid: string, start: string, end: string, withChart: boolean) => {
if (!uid) return;
if (start && end && start > end) {
setError('请选择有效的时间范围');
return;
}
if ((start && !end) || (!start && end)) {
setError('请完整选择起止日期');
return;
}
const filtered = Boolean(start && end);
setLoading(true);
setError(null);
try {
const data = await listAdminUsage({ userId: userId || undefined, page: p, pageSize: PAGE_SIZE });
const listParams: { userId: string; page: number; pageSize: number; startAt?: number; endAt?: number } = {
userId: uid,
page: p,
pageSize: PAGE_SIZE,
};
let statsPromise: Promise<UsageStatsResult | null> = Promise.resolve(null);
let summaryPromise: Promise<UsageSummaryResult | null> = Promise.resolve(null);
if (filtered) {
const { startAt, endAt } = dateRangeToUnix(start, end);
listParams.startAt = startAt;
listParams.endAt = endAt;
if (withChart) {
statsPromise = getAdminUsageStats({ userId: uid, startAt, endAt });
summaryPromise = getAdminUsageSummary({ userId: uid, startAt, endAt });
}
} else if (withChart) {
const endAt = Math.floor(Date.now() / 1000);
statsPromise = getAdminUsageStats({ userId: uid, startAt: 1, endAt });
summaryPromise = getAdminUsageSummary({ userId: uid });
}
const [data, statsData, summaryData] = await Promise.all([
listAdminUsage(listParams),
statsPromise,
summaryPromise,
]);
setResult(data);
setFilterUserId(userId);
if (statsData) setStats(statsData);
if (summaryData) setSummary(summaryData);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
@@ -183,68 +606,165 @@ function UsageTab() {
}
}, []);
useEffect(() => { void load(1, ''); }, [load]);
useEffect(() => {
if (!userId) {
setResult(null);
setStats(null);
setSummary(null);
setError(null);
return;
}
void load(1, userId, startDate, endDate, true);
}, [userId, startDate, endDate, load]);
const handleQuery = () => void load(1, pendingUserId);
const handlePage = (p: number) => void load(p, filterUserId);
const applyPreset = (days: number) => {
const range = presetDateRange(days);
setStartDate(range.startDate);
setEndDate(range.endDate);
};
const clearDateFilter = () => {
setStartDate('');
setEndDate('');
};
const handlePage = (p: number) => void load(p, userId, startDate, endDate, false);
const selectedUser = users.find((u) => u.id === userId);
const chartHint = hasDateFilter
? `${startDate}${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''}`
: `全部日期${selectedUser ? ` · ${selectedUser.displayName}` : ''}`;
const rangeSummaryLabel = hasDateFilter
? `区间汇总(${startDate}${endDate}${selectedUser ? ` · ${selectedUser.displayName}` : ''}`
: '';
const allTimeSummaryLabel = selectedUser
? `累计汇总(${selectedUser.displayName} · 全部日期)`
: '累计汇总(全部日期)';
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<div className="billing-toolbar">
<UserCombobox
users={users}
value={pendingUserId}
onChange={setPendingUserId}
placeholder="全部用户"
<>
<div className="billing-toolbar usage-toolbar usage-query-toolbar">
<UserCombobox
users={users}
value={userId}
onChange={setUserId}
placeholder="选择用户"
/>
<div className="usage-date-range">
<input
type="date"
className="billing-filter-select usage-date-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={!userId}
/>
<button type="button" className="send-btn billing-query-btn" onClick={handleQuery} disabled={loading}>
<span className="muted usage-date-sep"></span>
<input
type="date"
className="billing-filter-select usage-date-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={!userId}
/>
</div>
<div className="usage-preset-btns">
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(7)}>7</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(30)}>30</button>
<button type="button" className="admin-btn-secondary usage-preset-btn" disabled={!userId} onClick={() => applyPreset(90)}>90</button>
<button
type="button"
className={`admin-btn-secondary usage-preset-btn${userId && !hasDateFilter ? ' active' : ''}`}
disabled={!userId}
onClick={clearDateFilter}
>
</button>
</div>
</div>
{error && <p className="banner banner-error">{error}</p>}
{!result && !loading && (
<p className="muted billing-empty"></p>
{!userId && !loading && (
<p className="muted billing-empty"></p>
)}
{loading && <p className="muted"></p>}
{!loading && result && (
{userId && loading && !result && !stats && <p className="muted"></p>}
{userId && selectedUser && (
<div className="usage-user-balance-bar">
<span className="usage-user-balance-name">
{selectedUser.displayName}
<span className="muted"> @{selectedUser.username}</span>
</span>
<span className="usage-user-balance-label muted"></span>
<span className="usage-user-balance-value">¥{formatYuan(selectedUser.balanceCents)}</span>
</div>
)}
{userId && (
<UsageSummaryPanel
summary={summary}
rangeLabel={rangeSummaryLabel}
allTimeLabel={allTimeSummaryLabel}
loading={loading && !summary}
/>
)}
{userId && !loading && stats && (
<UsageChartPanel
stats={stats}
chartMetrics={chartMetrics}
chartType={chartType}
onMetricsChange={setChartMetrics}
onChartTypeChange={setChartType}
title="用户用量趋势"
hint={chartHint}
/>
)}
{userId && !loading && result && (
result.items.length === 0 ? (
<p className="muted billing-empty"></p>
<p className="muted billing-empty">{hasDateFilter ? '该时间范围内暂无记录' : '该用户暂无用量记录'}</p>
) : (
<>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}> Token</th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>@{row.username}</td>
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={result.page} totalPages={result.totalPages} total={result.total}
pageSize={result.pageSize} onChange={handlePage} />
</>
<UsageRecordsTable result={result} onPage={handlePage} />
)
)}
</>
);
}
function UsageTab() {
const location = useLocation();
const navigate = useNavigate();
const view: UsageView = location.pathname.endsWith('/query') ? 'query' : 'overview';
const setView = (next: UsageView) => {
navigate(next === 'query' ? '/billing/usage/query' : '/billing/usage');
};
return (
<section className="admin-card">
<div className="admin-card-head usage-tab-head">
<h2></h2>
<div className="usage-view-switch" role="tablist" aria-label="用量记录视图">
<button
type="button"
role="tab"
aria-selected={view === 'overview'}
className={`usage-view-btn${view === 'overview' ? ' active' : ''}`}
onClick={() => setView('overview')}
>
</button>
<button
type="button"
role="tab"
aria-selected={view === 'query'}
className={`usage-view-btn${view === 'query' ? ' active' : ''}`}
onClick={() => setView('query')}
>
</button>
</div>
</div>
<div className="usage-tab-body">
{view === 'overview' ? <UsageOverviewPanel /> : <UsageQueryPanel />}
</div>
</section>
);
}
@@ -296,7 +816,7 @@ function LedgerTab() {
</div>
{error && <p className="banner banner-error">{error}</p>}
{!result && !loading && (
<p className="muted billing-empty"></p>
<p className="muted billing-empty"></p>
)}
{loading && <p className="muted"></p>}
{!loading && result && (
@@ -922,7 +1442,9 @@ function SubscriptionsTab() {
}
export function BillingPage() {
const [activeTab, setActiveTab] = useState<TabKey>('subscriptions');
const location = useLocation();
const navigate = useNavigate();
const activeTab = tabFromPath(location.pathname);
return (
<div className="admin-page">
@@ -934,7 +1456,7 @@ export function BillingPage() {
{TABS.map((tab) => (
<button key={tab.key} type="button" role="tab" aria-selected={activeTab === tab.key}
className={`admin-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => setActiveTab(tab.key)}>
onClick={() => navigate(TAB_PATHS[tab.key])}>
{tab.label}
</button>
))}
+7 -1
View File
@@ -122,6 +122,12 @@ const CAPABILITIES: Array<{
fields: [
{ key: 'enabled', label: '启用组合召回', type: 'boolean' },
{ key: 'episodicEnabled', label: '事件记忆', type: 'boolean' },
{ key: 'episodicMode', label: '历史会话召回模式', type: 'select', options: [
{ value: 'off', label: 'Off · 关闭' },
{ value: 'canary', label: 'Canary · 仅指定用户' },
{ value: 'active', label: 'Active · 全量' },
] },
{ key: 'episodicCanaryUserIds', label: '历史会话 Canary 用户 ID(逗号分隔)', type: 'text' },
{ key: 'semanticEnabled', label: '语义记忆', type: 'boolean' },
{ key: 'preferenceEnabled', label: '用户偏好', type: 'boolean' },
{ key: 'goalEnabled', label: '活跃目标', type: 'boolean' },
@@ -346,7 +352,7 @@ function defaultConfig(): MemoryV2AdminConfig {
candidateMemory: { enabled: false, mode: 'active', minImportance: '0.7', minConfidence: '0.8', maxPending: '500', persistenceEnabled: false },
runtimeControl: { agentResolveEnabled: false, agentInjectionMode: 'off', agentCanaryUserIds: '', agentResolveLimit: '3', agentResolveTimeoutMs: '1200', promotionEnabled: false, compactionV2Enabled: false, reflectionEnabled: false, lifecycleWorkerEnabled: false, lifecycleRolloutMode: 'off', lifecycleRolloutUserIds: '' },
policy: { enabled: false, saveExplicit: true, rejectSensitive: true, requireEvidence: true, retentionDays: '365' },
retriever: { enabled: false, episodicEnabled: true, semanticEnabled: true, preferenceEnabled: true, goalEnabled: true, limit: '12', tokenBudget: '1800', timeoutMs: '1200' },
retriever: { enabled: false, episodicEnabled: true, episodicMode: 'off', episodicCanaryUserIds: '', semanticEnabled: true, preferenceEnabled: true, goalEnabled: true, limit: '12', tokenBudget: '1800', timeoutMs: '1200' },
lifecycle: { enabled: false, dedupeEnabled: true, conflictReview: true, decayEnabled: false, forgettingEnabled: true, compactIntervalHours: '24' },
persona: { enabled: false, provider: 'none', shadowMode: true, maxTokens: '400', cacheTtlSeconds: '300' },
graph: { enabled: false, provider: 'postgres', maxDepth: '2', relationLimit: '20' },
+251 -22
View File
@@ -1,23 +1,75 @@
import { useEffect, useState } from 'react';
import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client';
import type { MindSearchConfig } from '../../types';
import {
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 = {
enabled: false,
mode: 'off',
providers: { searxng: false, github: false, reader: false },
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() {
const [config, setConfig] = useState(initial);
const [llmKeys, setLlmKeys] = useState<LlmProviderKeyRow[]>([]);
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
const [message, setMessage] = useState('加载配置中…');
useEffect(() => {
getMindSearchConfig()
.then((result) => {
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
Promise.all([
getMindSearchConfig(),
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
])
.then(([result, keys]) => {
setConfig(mergeConfig(result.config));
setLlmKeys(keys.filter((key) => key.status === 'active'));
setMeta(result);
setMessage('');
})
@@ -28,7 +80,7 @@ export function MindSearchPage() {
setMessage('保存中…');
try {
const result = await updateMindSearchConfig(config);
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
setConfig(mergeConfig(result.config));
setMeta(result);
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
} 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) =>
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 (
<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">
<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>
<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>
<h2>Provider</h2>
<fieldset><legend> Provider</legend>
<label><input type="checkbox" checked={config.providers.searxng} onChange={() => toggleProvider('searxng')} /> SearXNG Web/News</label>
<label><input type="checkbox" checked={config.providers.github} onChange={() => toggleProvider('github')} /> GitHub CodeToken </label>
<label><input type="checkbox" checked={config.providers.reader} onChange={() => toggleProvider('reader')} /> Reader localhost/</label>
</fieldset>
<div className="admin-form">
<label className="search-service-toggle">
<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',
})}
/>
<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>
<label>SearXNG <input value={config.settings.searxngEndpoint} placeholder="http://127.0.0.1:8080/search" onChange={(event) => updateSetting('searxngEndpoint', event.target.value)} /></label>
<label> <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>Reader <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 className="admin-form">
<label><span></span><input type="number" min={1} max={20} value={config.settings.maxResults} onChange={(event) => updateSetting('maxResults', 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><span>Reader </span><input type="number" min={1000} max={50000} value={config.settings.readerMaxChars} onChange={(event) => updateSetting('readerMaxChars', Number(event.target.value))} /></label>
</div>
<button className="send-btn" type="button" onClick={save}></button>
{message && <p role="status">{message}</p>}
<p>{meta.source === 'admin' ? '数据库' : '环境变量默认值'}{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
</div>
+383
View File
@@ -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>
);
}
+230
View File
@@ -35,12 +35,17 @@ import type {
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
MemoryV2RuntimeStatusResponse,
MemoryV2ConfigRuntimeState,
SkillRuntimeAdminConfig,
SkillRuntimeAdminConfigResponse,
SkillRuntimeCatalogItem,
SkillDefinition,
SkillMap,
UsageRecord,
UsageStatsBucket,
UsageStatsResult,
UsageSummaryResult,
UsageTotals,
WechatAdminSummary,
WechatBinding,
WechatDeliveryLog,
@@ -49,7 +54,17 @@ import type {
WechatMessage,
WechatWebNotification,
MindSearchConfig,
MindSearchServiceTestResult,
} from '../types';
import type {
OrchestratorCanaryReadiness,
OrchestratorConfig,
OrchestratorConfigState,
OrchestratorExecutionPlanList,
OrchestratorRuntimeState,
OrchestratorShadowRunDetail,
OrchestratorShadowRunList,
} from '../types/orchestrator';
export class ApiError extends Error {
readonly status: number;
@@ -219,11 +234,15 @@ export async function listAdminUsage(params?: {
userId?: string;
page?: number;
pageSize?: number;
startAt?: number;
endAt?: number;
}): Promise<PagedResult<UsageRecord>> {
const q = new URLSearchParams();
if (params?.userId) q.set('userId', params.userId);
if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('pageSize', String(params.pageSize));
if (params?.startAt) q.set('startAt', String(params.startAt));
if (params?.endAt) q.set('endAt', String(params.endAt));
const result = await portalFetch<{ records: UsageRecord[]; total: number; page: number; pageSize: number }>(
`/admin-api/usage${q.toString() ? `?${q}` : ''}`,
);
@@ -232,6 +251,146 @@ export async function listAdminUsage(params?: {
return { items: result.records ?? [], total, page: result.page ?? 1, pageSize, totalPages: Math.ceil(total / pageSize) };
}
function toMillis(ts: number) {
return ts >= 1_000_000_000_000 ? ts : ts * 1000;
}
function usageDayKey(createdAt: number) {
const d = new Date(toMillis(createdAt));
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
async function aggregateUsageStatsFromRecords(params: {
userId?: string;
startAt: number;
endAt: number;
}): Promise<UsageStatsResult> {
const bucketMap = new Map<string, UsageStatsBucket>();
let page = 1;
const pageSize = 200;
const maxPages = 50;
const startMs = params.startAt * 1000;
const endMs = params.endAt * 1000 + 999;
while (page <= maxPages) {
const result = await listAdminUsage({
userId: params.userId,
page,
pageSize,
startAt: params.startAt,
endAt: params.endAt,
});
for (const record of result.items) {
const createdMs = toMillis(record.createdAt);
if (createdMs < startMs || createdMs > endMs) continue;
const day = usageDayKey(record.createdAt);
const bucket = bucketMap.get(day) ?? {
day,
count: 0,
inputTokens: 0,
outputTokens: 0,
costCents: 0,
};
bucket.count += 1;
bucket.inputTokens += record.inputTokens;
bucket.outputTokens += record.outputTokens;
bucket.costCents += record.costCents;
bucketMap.set(day, bucket);
}
if (page >= result.totalPages || result.items.length === 0) break;
page += 1;
}
return {
buckets: [...bucketMap.values()].sort((a, b) => a.day.localeCompare(b.day)),
startAt: params.startAt,
endAt: params.endAt,
};
}
function sumUsageBuckets(buckets: UsageStatsBucket[]): UsageTotals {
return buckets.reduce(
(acc, b) => ({
count: acc.count + b.count,
inputTokens: acc.inputTokens + b.inputTokens,
outputTokens: acc.outputTokens + b.outputTokens,
costCents: acc.costCents + b.costCents,
}),
{ count: 0, inputTokens: 0, outputTokens: 0, costCents: 0 },
);
}
export async function getAdminUsageSummary(params?: {
userId?: string;
startAt?: number;
endAt?: number;
}): Promise<UsageSummaryResult> {
const q = new URLSearchParams();
if (params?.userId) q.set('userId', params.userId);
if (params?.startAt) q.set('startAt', String(params.startAt));
if (params?.endAt) q.set('endAt', String(params.endAt));
const suffix = q.toString() ? `?${q}` : '';
try {
return await portalFetch<UsageSummaryResult>(`/admin-api/usage/summary${suffix}`);
} catch (err) {
if (!(err instanceof ApiError) || err.status !== 404) throw err;
}
const allTime = sumUsageBuckets(
(await aggregateUsageStatsFromRecords({
userId: params?.userId,
startAt: 0,
endAt: Math.floor(Date.now() / 1000),
})).buckets,
);
if (!params?.startAt || !params?.endAt) {
return { range: null, allTime };
}
const rangeRows = await getAdminUsageStats({
userId: params.userId,
startAt: params.startAt,
endAt: params.endAt,
});
return {
range: {
...sumUsageBuckets(rangeRows.buckets),
startAt: params.startAt,
endAt: params.endAt,
},
allTime,
};
}
export async function getAdminUsageStats(params: {
userId?: string;
startAt: number;
endAt: number;
}): Promise<UsageStatsResult> {
const q = new URLSearchParams();
if (params.userId) q.set('userId', params.userId);
q.set('startAt', String(params.startAt));
q.set('endAt', String(params.endAt));
const attempts = [
`/admin-api/usage/stats?${q}`,
`/admin-api/usage?${q}&stats=1`,
];
for (const path of attempts) {
try {
return await portalFetch<UsageStatsResult>(path);
} catch (err) {
if (err instanceof ApiError && err.status === 404) continue;
throw err;
}
}
return aggregateUsageStatsFromRecords(params);
}
export async function listAdminLedger(params?: {
userId?: string;
page?: number;
@@ -307,6 +466,7 @@ export async function updateAssetPluginConfig(
provider?: string | null;
llmProviderKeyId?: string | null;
llmModel?: string | null;
purposes?: Record<string, boolean>;
},
): Promise<{ ok: boolean; config: AssetGatewayConfig }> {
return portalFetch(`/admin-api/asset-gateway/plugins/${encodeURIComponent(pluginId)}`, {
@@ -325,6 +485,11 @@ export async function getUmamiSsoUrl(): Promise<string> {
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(
payload: Partial<MindSpaceAdminConfig> & { analytics?: MindSpaceAnalyticsUpdate },
): Promise<MindSpaceAdminConfig> {
@@ -343,6 +508,10 @@ export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusR
return portalFetch('/admin-api/memory-v2/status');
}
export async function getMemoryV2ConfigRuntime(): Promise<MemoryV2ConfigRuntimeState> {
return portalFetch('/admin-api/memory-v2/runtime');
}
export async function listPersonalMemoryCandidates(
status = 'candidate',
): Promise<PersonalMemoryCandidateListResponse> {
@@ -668,6 +837,67 @@ export async function updateMindSearchConfig(config: Partial<MindSearchConfig>):
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 ──────────────────────────────────────────
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
+251
View File
@@ -0,0 +1,251 @@
import { useMemo } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UsageStatsBucket } from '../types';
import { formatYuan } from '../admin/utils/format';
export type UsageChartMetric = 'cost' | 'tokens' | 'count';
export type UsageChartType = 'bar' | 'line';
export const METRIC_OPTIONS: { key: UsageChartMetric; label: string }[] = [
{ key: 'tokens', label: 'Token 总量' },
{ key: 'cost', label: '扣费金额' },
{ key: 'count', label: '请求次数' },
];
export const METRIC_CONFIG: Record<
UsageChartMetric,
{ label: string; color: string; yAxisId: 'left' | 'right' }
> = {
tokens: { label: 'Token 总量', color: '#3d8bfd', yAxisId: 'left' },
cost: { label: '扣费金额', color: '#22c55e', yAxisId: 'right' },
count: { label: '请求次数', color: '#f59e0b', yAxisId: 'right' },
};
export const DEFAULT_CHART_METRICS: UsageChartMetric[] = ['tokens'];
function formatDayLabel(day: string) {
const [, month, date] = day.split('-');
return `${Number(month)}/${Number(date)}`;
}
function fillDailyBuckets(buckets: UsageStatsBucket[], startAt: number, endAt: number) {
const map = new Map(buckets.map((b) => [b.day, b]));
const start = new Date(startAt * 1000);
start.setHours(0, 0, 0, 0);
const end = new Date(endAt * 1000);
end.setHours(0, 0, 0, 0);
const filled: UsageStatsBucket[] = [];
for (let cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
const y = cursor.getFullYear();
const m = String(cursor.getMonth() + 1).padStart(2, '0');
const d = String(cursor.getDate()).padStart(2, '0');
const day = `${y}-${m}-${d}`;
filled.push(
map.get(day) ?? {
day,
count: 0,
inputTokens: 0,
outputTokens: 0,
costCents: 0,
},
);
}
return filled;
}
function formatMetricValue(value: number, metric: UsageChartMetric) {
if (metric === 'cost') return `¥${formatYuan(Math.round(value * 100))}`;
return value.toLocaleString();
}
type ChartPoint = {
day: string;
label: string;
tokens: number;
cost: number;
count: number;
inputTokens: number;
outputTokens: number;
costCents: number;
};
function UsageTooltip({
active,
payload,
metrics,
}: {
active?: boolean;
payload?: Array<{ payload: ChartPoint; dataKey?: string; color?: string }>;
metrics: UsageChartMetric[];
}) {
if (!active || !payload?.length) return null;
const row = payload[0].payload;
return (
<div className="usage-chart-tooltip">
<div className="usage-chart-tooltip-title">{row.day}</div>
{metrics.map((metric) => (
<div key={metric} className="usage-chart-tooltip-metric">
<span className="usage-chart-tooltip-dot" style={{ background: METRIC_CONFIG[metric].color }} />
{METRIC_CONFIG[metric].label}{formatMetricValue(row[metric], metric)}
</div>
))}
<div className="muted usage-chart-tooltip-detail">
{row.inputTokens.toLocaleString()} / {row.outputTokens.toLocaleString()} Token
</div>
</div>
);
}
export function UsageTrendChart({
buckets,
startAt,
endAt,
metrics,
chartType,
}: {
buckets: UsageStatsBucket[];
startAt: number;
endAt: number;
metrics: UsageChartMetric[];
chartType: UsageChartType;
}) {
const activeMetrics = metrics.length > 0 ? metrics : DEFAULT_CHART_METRICS;
const data = useMemo(() => {
const filled = fillDailyBuckets(buckets, startAt, endAt);
return filled.map((bucket) => ({
day: bucket.day,
label: formatDayLabel(bucket.day),
tokens: bucket.inputTokens + bucket.outputTokens,
cost: bucket.costCents / 100,
count: bucket.count,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
costCents: bucket.costCents,
}));
}, [buckets, startAt, endAt]);
const hasData = data.some((row) => activeMetrics.some((metric) => row[metric] > 0));
const showLeftAxis = activeMetrics.includes('tokens');
const showRightAxis = activeMetrics.some((m) => m === 'cost' || m === 'count');
const resolveYAxisId = (metric: UsageChartMetric): 'left' | 'right' => {
if (showLeftAxis && showRightAxis) return METRIC_CONFIG[metric].yAxisId;
if (showLeftAxis) return 'left';
return 'right';
};
if (!hasData) {
return <p className="muted usage-chart-empty"></p>;
}
return (
<div className="usage-chart-wrap">
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={data} margin={{ top: 8, right: showRightAxis ? 16 : 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-subtle)" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-text-muted)' }}
interval={data.length > 14 ? Math.floor(data.length / 7) : 0}
/>
{showLeftAxis && (
<YAxis
yAxisId="left"
tick={{ fontSize: 11, fill: METRIC_CONFIG.tokens.color }}
tickFormatter={(v) => (v >= 10000 ? `${(v / 10000).toFixed(0)}` : String(v))}
width={52}
/>
)}
{showRightAxis && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 11, fill: 'var(--color-text-muted)' }}
tickFormatter={(v) => (Number.isInteger(v) ? String(v) : v.toFixed(2))}
width={48}
/>
)}
<Tooltip content={<UsageTooltip metrics={activeMetrics} />} />
<Legend
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
formatter={(value) => <span style={{ color: 'var(--color-text-muted)' }}>{value}</span>}
/>
{activeMetrics.map((metric) => {
const cfg = METRIC_CONFIG[metric];
const axisId = resolveYAxisId(metric);
if (chartType === 'bar') {
return (
<Bar
key={metric}
yAxisId={axisId}
dataKey={metric}
name={cfg.label}
fill={cfg.color}
radius={[4, 4, 0, 0]}
maxBarSize={activeMetrics.length > 1 ? 24 : 40}
/>
);
}
return (
<Line
key={metric}
yAxisId={axisId}
type="monotone"
dataKey={metric}
name={cfg.label}
stroke={cfg.color}
strokeWidth={2}
dot={{ r: 3, fill: cfg.color }}
activeDot={{ r: 5 }}
/>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
export function toggleChartMetric(current: UsageChartMetric[], metric: UsageChartMetric): UsageChartMetric[] {
if (current.includes(metric)) {
if (current.length === 1) return current;
return current.filter((m) => m !== metric);
}
return [...current, metric];
}
export function dateRangeToUnix(startDate: string, endDate: string) {
const [sy, sm, sd] = startDate.split('-').map(Number);
const [ey, em, ed] = endDate.split('-').map(Number);
const startAt = Math.floor(new Date(sy, sm - 1, sd, 0, 0, 0, 0).getTime() / 1000);
const endAt = Math.floor(new Date(ey, em - 1, ed, 23, 59, 59, 999).getTime() / 1000);
return { startAt, endAt };
}
export function formatDateInput(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function presetDateRange(days: number) {
const end = new Date();
end.setHours(0, 0, 0, 0);
const start = new Date(end);
start.setDate(start.getDate() - (days - 1));
return { startDate: formatDateInput(start), endDate: formatDateInput(end) };
}
+596
View File
@@ -446,6 +446,212 @@ body,
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 {
display: grid;
gap: 14px;
@@ -577,6 +783,51 @@ body,
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 {
grid-column: 1 / -1;
}
@@ -1080,6 +1331,60 @@ body,
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 {
display: flex;
align-items: center;
@@ -1792,6 +2097,297 @@ body,
text-align: center;
}
.usage-toolbar {
flex-wrap: wrap;
}
.usage-tab-head {
flex-wrap: wrap;
gap: 12px;
}
.usage-view-switch {
display: inline-flex;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
overflow: hidden;
}
.usage-view-btn {
padding: 6px 14px;
font-size: 13px;
border: none;
background: var(--color-bg-base);
color: var(--color-text-muted);
cursor: pointer;
}
.usage-view-btn.active {
background: var(--color-accent);
color: #fff;
}
.usage-tab-body {
margin-top: 4px;
}
.usage-query-toolbar {
margin-bottom: 16px;
}
.usage-user-balance-bar {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 16px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
}
.usage-user-balance-name {
font-weight: 600;
margin-right: auto;
}
.usage-user-balance-value {
font-size: 20px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.usage-summary-panel {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
.usage-summary-block {
padding: 14px 16px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
}
.usage-summary-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
}
.usage-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.usage-summary-grid .admin-stat-card {
padding: 10px 12px;
}
.usage-summary-grid .admin-stat-value {
font-size: 20px;
}
.usage-summary-sub {
margin-top: 4px;
font-size: 11px;
}
.usage-summary-loading {
margin-bottom: 12px;
}
.usage-overview-filter,
.usage-overview-chart-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.usage-overview-chart-label {
font-size: 13px;
}
.usage-chart-hint {
margin: 4px 0 0;
font-size: 12px;
}
.usage-preset-btn.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.usage-date-range {
display: flex;
align-items: center;
gap: 6px;
}
.usage-date-input {
width: 138px;
}
.usage-date-sep {
font-size: 12px;
}
.usage-preset-btns {
display: flex;
gap: 4px;
}
.usage-preset-btn {
padding: 5px 10px;
font-size: 12px;
min-width: unset;
}
.usage-chart-panel {
margin: 16px 0 20px;
padding: 16px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
}
.usage-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.usage-chart-head h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.usage-chart-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.usage-metric-checks {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.usage-metric-check {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
font-size: 12px;
cursor: pointer;
user-select: none;
background: var(--color-bg-base);
color: var(--color-text-muted);
}
.usage-metric-check.checked {
border-color: var(--color-border-subtle);
color: var(--color-text-primary);
}
.usage-metric-check input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.usage-metric-check-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.usage-chart-tooltip-metric {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.usage-chart-tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.usage-chart-tooltip-detail {
margin-top: 6px;
font-size: 11px;
}
.usage-chart-select {
min-width: 120px;
padding: 5px 8px;
font-size: 12px;
}
.usage-chart-type-toggle {
display: inline-flex;
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
overflow: hidden;
}
.usage-chart-type-btn {
padding: 5px 12px;
font-size: 12px;
border: none;
background: var(--color-bg-base);
color: var(--color-text-muted);
cursor: pointer;
}
.usage-chart-type-btn.active {
background: var(--color-accent);
color: #fff;
}
.usage-chart-wrap {
width: 100%;
}
.usage-chart-empty {
padding: 40px 0;
text-align: center;
}
.usage-chart-tooltip {
padding: 8px 10px;
border-radius: var(--radius-md);
background: var(--color-bg-base);
border: 1px solid var(--color-border-subtle);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
font-size: 12px;
line-height: 1.5;
}
.usage-chart-tooltip-title {
font-weight: 600;
margin-bottom: 4px;
}
.text-income {
color: #22c55e;
}
+81
View File
@@ -208,6 +208,12 @@ export type AssetPluginConfig = {
llmProviderId: string | null;
llmModel: string | null;
updatedAt: number | null;
purposes?: Record<string, boolean>;
purposeCatalog?: Array<{
id: string;
label: string;
presetId: string;
}>;
};
export type AssetGatewayConfig = {
@@ -231,6 +237,7 @@ export type UsageRecord = {
id: number;
userId: string;
username: string;
displayName: string;
agentSessionId: string;
requestId: string | null;
inputTokens: number;
@@ -240,6 +247,37 @@ export type UsageRecord = {
createdAt: number;
};
export type UsageStatsBucket = {
day: string;
count: number;
inputTokens: number;
outputTokens: number;
costCents: number;
};
export type UsageTotals = {
count: number;
inputTokens: number;
outputTokens: number;
costCents: number;
};
export type UsageRangeTotals = UsageTotals & {
startAt: number;
endAt: number;
};
export type UsageSummaryResult = {
range: UsageRangeTotals | null;
allTime: UsageTotals;
};
export type UsageStatsResult = {
buckets: UsageStatsBucket[];
startAt: number;
endAt: number;
};
export type LedgerEntry = {
id: number;
userId: string;
@@ -336,6 +374,14 @@ export type MemoryV2AdminConfigResponse = {
updatedBy: string | null;
};
export type MemoryV2ConfigRuntimeState = {
source: string;
updatedAt: number | null;
updatedBy: string | null;
fingerprint?: string;
overrides: Record<string, string>;
};
export type MemoryV2RuntimeStatusResponse = {
ok: boolean;
checkedAt?: number;
@@ -619,4 +665,39 @@ export type MindSearchConfig = {
mode: 'off' | 'shadow' | 'assist';
providers: { searxng: boolean; github: boolean; reader: boolean };
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;
};
+237
View File
@@ -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;
};
};
};