From 7c2af3306a1e92477ea515792afed5084fd6dae1 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 16 Jul 2026 14:09:12 +0800 Subject: [PATCH] feat: add Memind analytics admin and Umami SSO --- .env.example | 4 ++ server/app.mjs | 13 ++++ src/App.tsx | 2 + src/admin/AdminNav.tsx | 1 + src/admin/pages/AnalyticsConfigPage.tsx | 82 +++++++++++++++++++++++++ src/admin/pages/MemoryV2Page.tsx | 45 +++++++++++++- src/admin/pages/MindSpacePage.tsx | 2 +- src/api/client.ts | 8 ++- src/types.ts | 16 +++++ 9 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/admin/pages/AnalyticsConfigPage.tsx diff --git a/.env.example b/.env.example index ae54e86..70a760e 100644 --- a/.env.example +++ b/.env.example @@ -38,5 +38,9 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users # 超管页「返回对话」跳转主 H5(可选) VITE_MAIN_APP_URL=https://h5.tkmind.cn +# Umami 仅允许从 MemindAdm 单点登录进入(必须与 memind-analytics 一致) +MEMIND_UMAMI_SSO_SECRET=replace-with-the-same-random-secret-used-by-umami +UMAMI_SSO_USERNAME=admin + # Plaza 帖子预览链接 # VITE_PLAZA_BASE=https://plaza.tkmind.cn diff --git a/server/app.mjs b/server/app.mjs index f52e986..6ea46dc 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import crypto from 'node:crypto'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -220,6 +221,17 @@ export function createAdminApp(services) { next(); } + adminApi.get('/analytics/sso', requireAdmin, async (req, res) => { + const sharedSecret = process.env.MEMIND_UMAMI_SSO_SECRET?.trim(); + if (!sharedSecret) return res.status(503).json({ message: '未配置 Umami 单点登录密钥' }); + const config = loadMindSpaceConfig ? await loadMindSpaceConfig(pool) : null; + const baseUrl = String(config?.analytics?.analyticsUrl || process.env.UMAMI_URL || 'http://127.0.0.1:3100').replace(/\/$/, ''); + const username = process.env.UMAMI_SSO_USERNAME?.trim() || 'admin'; + const encoded = Buffer.from(JSON.stringify({ username, 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}/auth/memind?ticket=${encoded}.${signature}` }); + }); + adminApi.get('/users', requireAdmin, async (req, res) => { const result = await userAuth.listUsers({ page: Number(req.query.page) || 1, @@ -325,6 +337,7 @@ export function createAdminApp(services) { if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' }); const result = await updateMindSpaceConfig(pool, { publicPageLimit: req.body?.publicPageLimit, + analytics: req.body?.analytics, }); res.json({ config: result }); }); diff --git a/src/App.tsx b/src/App.tsx index bbb91c2..3b09e1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { UsersPage } from './admin/pages/UsersPage'; import { WechatPage } from './admin/pages/WechatPage'; 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 { defaultHomePath } from './lib/routes'; import { OpsLayout } from './ops/components/OpsLayout'; @@ -122,6 +123,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index a411b20..122bf45 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -28,6 +28,7 @@ const NAV_SECTIONS: NavSection[] = [ items: [ { to: '/wechat', label: '服务号' }, { to: '/mindspace', label: 'MindSpace 配置' }, + { to: '/analytics', label: 'Analytics 配置' }, { to: '/memory-v2', label: 'Memory V2' }, { to: '/mindsearch', label: 'MindSearch' }, { to: '/skill-runtime', label: 'Skill Runtime' }, diff --git a/src/admin/pages/AnalyticsConfigPage.tsx b/src/admin/pages/AnalyticsConfigPage.tsx new file mode 100644 index 0000000..a0633f4 --- /dev/null +++ b/src/admin/pages/AnalyticsConfigPage.tsx @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { getMindSpaceAdminConfig, getUmamiSsoUrl, updateMindSpaceAdminConfig } from '../../api/client'; + +const initial = { + enabled: false, + websiteId: '', + analyticsUrl: 'http://127.0.0.1:3100', + domains: '127.0.0.1,localhost', + idSecretConfigured: false, +}; + +export function AnalyticsConfigPage() { + const [config, setConfig] = useState(initial); + const [secret, setSecret] = useState(''); + const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [opening, setOpening] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const result = await getMindSpaceAdminConfig(); + setConfig({ ...initial, ...result.analytics }); + } catch (err) { + setError(err instanceof Error ? err.message : '加载 Analytics 配置失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + const save = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + setMessage(null); + try { + const result = await updateMindSpaceAdminConfig({ + analytics: { enabled: config.enabled, websiteId: config.websiteId, analyticsUrl: config.analyticsUrl, domains: config.domains, ...(secret.trim() ? { idSecret: secret.trim() } : {}) }, + }); + setConfig({ ...initial, ...result.analytics }); + setSecret(''); + setMessage('Analytics 配置已保存;密钥不会在后台回显。'); + } catch (err) { + setError(err instanceof Error ? err.message : '保存 Analytics 配置失败'); + } finally { + setBusy(false); + } + }; + + const openUmami = async () => { + setOpening(true); + setError(null); + try { + window.location.assign(await getUmamiSsoUrl()); + } catch (err) { + setError(err instanceof Error ? err.message : '无法打开 Umami'); + setOpening(false); + } + }; + + return
+

Analytics 配置

配置本地 Memind 生成页面的 Umami 统计。默认只连接本机 Umami,不涉及生产环境。

+ {error &&

{error}

} + {message &&

{message}

} +
+

本地 Umami

+
+
+ + + + + +
+
+
+
; +} diff --git a/src/admin/pages/MemoryV2Page.tsx b/src/admin/pages/MemoryV2Page.tsx index b86d6eb..e21352a 100644 --- a/src/admin/pages/MemoryV2Page.tsx +++ b/src/admin/pages/MemoryV2Page.tsx @@ -31,6 +31,7 @@ type ModelBackendKey = 'mem0' | 'letta' | 'langgraph'; type CapabilityKey = | 'candidateMemory' + | 'runtimeControl' | 'policy' | 'retriever' | 'lifecycle' @@ -42,7 +43,7 @@ type CapabilityKey = type CapabilityField = { key: string; label: string; - type: 'boolean' | 'number' | 'select'; + type: 'boolean' | 'number' | 'select' | 'text'; options?: Array<{ value: string; label: string }>; }; @@ -72,6 +73,34 @@ const CAPABILITIES: Array<{ { key: 'persistenceEnabled', label: '持久化到 MySQL', type: 'boolean' }, ], }, + { + key: 'runtimeControl', + label: 'Runtime 控制', + icon: 'A', + description: '独立于 Memory V2 总开关,控制 Agent 读取、注入、晋升与后续生命周期能力。', + fields: [ + { key: 'agentResolveEnabled', label: '允许 Agent Resolve', type: 'boolean' }, + { key: 'agentInjectionMode', label: 'Agent 注入模式', type: 'select', options: [ + { value: 'off', label: 'Off · 关闭' }, + { value: 'shadow', label: 'Shadow · 只观察不注入' }, + { value: 'canary', label: 'Canary · 灰度注入' }, + { value: 'active', label: 'Active · 正式注入' }, + ] }, + { key: 'agentCanaryUserIds', label: 'Canary 用户 ID(逗号分隔)', type: 'text' }, + { key: 'agentResolveLimit', label: 'Agent 召回条数', type: 'number' }, + { key: 'agentResolveTimeoutMs', label: 'Agent Resolve 超时 Ms', type: 'number' }, + { key: 'promotionEnabled', label: '允许候选晋升正式记忆', type: 'boolean' }, + { key: 'compactionV2Enabled', label: '启用 Compact V2', type: 'boolean' }, + { key: 'reflectionEnabled', label: '启用 Reflection', type: 'boolean' }, + { key: 'lifecycleWorkerEnabled', label: '启用 Lifecycle Worker', type: 'boolean' }, + { key: 'lifecycleRolloutMode', label: 'Lifecycle 灰度模式', type: 'select', options: [ + { value: 'off', label: 'Off · 关闭' }, + { value: 'canary', label: 'Canary · 仅指定用户' }, + { value: 'active', label: 'Active · 全量' }, + ] }, + { key: 'lifecycleRolloutUserIds', label: 'Lifecycle 用户 ID(逗号分隔)', type: 'text' }, + ], + }, { key: 'policy', label: 'Policy', @@ -315,6 +344,7 @@ function defaultConfig(): MemoryV2AdminConfig { fallbackRoute: 'agent_orchestration', }, 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' }, lifecycle: { enabled: false, dedupeEnabled: true, conflictReview: true, decayEnabled: false, forgettingEnabled: true, compactIntervalHours: '24' }, @@ -339,6 +369,7 @@ function normalizeConfig(config?: Partial | null): MemoryV2 global: { ...base.global, ...(config?.global ?? {}) }, chatIntentRouter: { ...base.chatIntentRouter, ...(config?.chatIntentRouter ?? {}) }, candidateMemory: { ...base.candidateMemory, ...(config?.candidateMemory ?? {}) }, + runtimeControl: { ...base.runtimeControl, ...(config?.runtimeControl ?? {}) }, policy: { ...base.policy, ...(config?.policy ?? {}) }, retriever: { ...base.retriever, ...(config?.retriever ?? {}) }, lifecycle: { ...base.lifecycle, ...(config?.lifecycle ?? {}) }, @@ -933,6 +964,18 @@ export function MemoryV2Page() { ); } + if (field.type === 'text') { + return ( + + ); + } return (