Compare commits

..

2 Commits

Author SHA1 Message Date
john 7c2af3306a feat: add Memind analytics admin and Umami SSO 2026-07-16 14:09:12 +08:00
john 23a5f147fd merge: integrate MindSearch admin controls 2026-07-15 13:47:19 +08:00
9 changed files with 170 additions and 3 deletions
+4
View File
@@ -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
+13
View File
@@ -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 });
});
+2
View File
@@ -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 }
<Route path="system-tests" element={<SystemTestsPage />} />
<Route path="policies" element={<PoliciesPage />} />
<Route path="mindspace" element={<MindSpacePage />} />
<Route path="analytics" element={<AnalyticsConfigPage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="mindsearch" element={<MindSearchPage />} />
<Route path="skill-runtime" element={<SkillRuntimePage />} />
+1
View File
@@ -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' },
+82
View File
@@ -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<string | null>(null);
const [message, setMessage] = useState<string | null>(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 <div className="admin-page">
<div className="admin-page-head"><h2>Analytics </h2><p className="muted"> Memind Umami 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>
<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>
<label className="admin-form-row"><span>Umami </span><input value={config.analyticsUrl} disabled={loading || busy} onChange={(e) => setConfig({ ...config, analyticsUrl: e.target.value })} placeholder="http://127.0.0.1:3100" /></label>
<label className="admin-form-row"><span></span><input value={config.domains} disabled={loading || busy} onChange={(e) => setConfig({ ...config, domains: e.target.value })} placeholder="127.0.0.1,localhost" /></label>
<label className="admin-form-row"><span> {config.idSecretConfigured ? '(已配置,留空保持不变)' : ''}</span><input type="password" value={secret} disabled={loading || busy} onChange={(e) => setSecret(e.target.value)} placeholder={config.idSecretConfigured ? '留空保持当前密钥' : '输入本地随机密钥'} autoComplete="new-password" /></label>
<div className="admin-actions"><button type="submit" className="send-btn" disabled={busy || loading}>{busy ? '保存中...' : '保存配置'}</button><button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}></button></div>
</form>
</section>
</div>;
}
+44 -1
View File
@@ -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<MemoryV2AdminConfig> | 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() {
</label>
);
}
if (field.type === 'text') {
return (
<label key={field.key}>
<span>{field.label}</span>
<input
type="text"
value={String(section[field.key] ?? '')}
onChange={updateCapabilityValue(capability.key, field.key)}
/>
</label>
);
}
return (
<label key={field.key}>
<span>{field.label}</span>
+1 -1
View File
@@ -3,7 +3,7 @@ import { getMindSpaceAdminConfig, updateMindSpaceAdminConfig } from '../../api/c
import type { MindSpaceAdminConfig } from '../../types';
function safeConfig(config: MindSpaceAdminConfig | null): MindSpaceAdminConfig {
return config ?? { publicPageLimit: 10 };
return config ?? { publicPageLimit: 10, analytics: { enabled: false, websiteId: '', analyticsUrl: 'http://127.0.0.1:3100', domains: '127.0.0.1,localhost', idSecretConfigured: false } };
}
export function MindSpacePage() {
+7 -1
View File
@@ -30,6 +30,7 @@ import type {
PolicyMap,
PortalUser,
MindSpaceAdminConfig,
MindSpaceAnalyticsUpdate,
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
@@ -319,8 +320,13 @@ export async function getMindSpaceAdminConfig(): Promise<MindSpaceAdminConfig> {
return result.config;
}
export async function getUmamiSsoUrl(): Promise<string> {
const result = await portalFetch<{ url: string }>('/admin-api/analytics/sso');
return result.url;
}
export async function updateMindSpaceAdminConfig(
payload: Partial<MindSpaceAdminConfig>,
payload: Partial<MindSpaceAdminConfig> & { analytics?: MindSpaceAnalyticsUpdate },
): Promise<MindSpaceAdminConfig> {
const result = await portalFetch<{ config: MindSpaceAdminConfig }>('/admin-api/mindspace/config', {
method: 'PATCH',
+16
View File
@@ -281,6 +281,21 @@ export type WechatScheduleLlmConfig = {
export type MindSpaceAdminConfig = {
publicPageLimit: number;
analytics: {
enabled: boolean;
websiteId: string;
analyticsUrl: string;
domains: string;
idSecretConfigured: boolean;
};
};
export type MindSpaceAnalyticsUpdate = {
enabled?: boolean;
websiteId?: string;
analyticsUrl?: string;
domains?: string;
idSecret?: string;
};
export type MemoryV2AdminSection = Record<string, string | boolean>;
@@ -297,6 +312,7 @@ export type MemoryV2AdminConfig = {
};
chatIntentRouter: MemoryV2AdminSection;
candidateMemory: MemoryV2AdminSection;
runtimeControl: MemoryV2AdminSection;
policy: MemoryV2AdminSection;
retriever: MemoryV2AdminSection;
lifecycle: MemoryV2AdminSection;