feat(admin): add News Engine console and image-make config API

Expose memind-news-engine health, collect, rank, and scheduler controls in md.tkmind.cn admin, proxied via MEMIND_NEWS_ENGINE_URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-22 14:36:38 +08:00
parent eba37640e4
commit 9ec4dda6af
11 changed files with 1097 additions and 0 deletions
+5
View File
@@ -35,6 +35,11 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
# Memind 业务模块路径(user-auth / llm-providers 等,默认 ../Memind
# MEMIND_LIB_ROOT=/Users/john/Project/Memind
# Memind News Engine(早报采集/排序服务,默认本地 8092)
# MEMIND_NEWS_ENGINE_URL=http://127.0.0.1:8092
# MEMIND_NEWS_ENGINE_API_TOKEN=
# MEMIND_NEWS_ENGINE_TIMEOUT_MS=120000
# 超管页「返回对话」跳转主 H5(可选)
VITE_MAIN_APP_URL=https://h5.tkmind.cn
+125
View File
@@ -7,6 +7,7 @@ import express from 'express';
import { listUsagePaged, listLedgerPaged, getUsageStats, getUsageSummary } from './pagination.mjs';
import { fetchMemindDiscoveryPages } from './umami-analytics.mjs';
import { importMemind } from './lib-path.mjs';
import { fetchNewsEngine } from './news-engine-admin.mjs';
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -95,6 +96,7 @@ export function createAdminApp(services) {
userAuth,
llmProviderService,
assetGatewayConfigService,
imageMakeAdminConfigService,
pool,
ready,
wechatAdmin,
@@ -624,6 +626,64 @@ export function createAdminApp(services) {
res.json(result);
});
adminApi.get('/image-make/config', requireAdmin, async (_req, res) => {
if (!imageMakeAdminConfigService?.getAdminConfig) {
return res.status(503).json({ message: 'image_make 配置服务未启用' });
}
return res.json(await imageMakeAdminConfigService.getAdminConfig());
});
const updateImageMakeConfig = async (req, res) => {
if (!imageMakeAdminConfigService?.updateAdminConfig) {
return res.status(503).json({ message: 'image_make 配置服务未启用' });
}
const result = await imageMakeAdminConfigService.updateAdminConfig(req.body ?? {}, {
updatedBy: req.currentUser.id,
});
if (result.ok === false) return res.status(400).json({ message: result.message });
return res.json(result);
};
adminApi.put('/image-make/config', requireAdmin, updateImageMakeConfig);
adminApi.patch('/image-make/config', requireAdmin, updateImageMakeConfig);
adminApi.get('/image-make/runtime', requireAdmin, async (_req, res) => {
if (!imageMakeAdminConfigService?.getRuntimeConfig) {
return res.status(503).json({ message: 'image_make 配置服务未启用' });
}
const runtime = await imageMakeAdminConfigService.getRuntimeConfig();
if (!runtime.ok) {
return res.status(503).json({ message: runtime.message ?? 'image_make 运行时配置无效' });
}
const { providers, ...rest } = runtime;
return res.json({
...rest,
providers: {
mock: { enabled: providers.mock.enabled },
aliyun_bailian: providers.aliyun_bailian.enabled
? {
enabled: true,
model: providers.aliyun_bailian.model,
apiBase: providers.aliyun_bailian.apiBase,
apiKeyConfigured: Boolean(providers.aliyun_bailian.apiKey),
}
: { enabled: false },
comfyui: providers.comfyui.enabled
? { enabled: true, ...providers.comfyui }
: { enabled: false },
fal_ai: providers.fal_ai.enabled
? {
enabled: true,
model: providers.fal_ai.model,
apiBase: providers.fal_ai.apiBase,
numInferenceSteps: providers.fal_ai.numInferenceSteps,
apiKeyConfigured: Boolean(providers.fal_ai.apiKey),
}
: { enabled: false },
},
});
});
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
res.json(await memoryV2ConfigService.getAdminConfig());
@@ -674,6 +734,71 @@ export function createAdminApp(services) {
}
});
adminApi.get('/news-engine/health', requireAdmin, async (_req, res) => {
const result = await fetchNewsEngine('/health');
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
});
adminApi.get('/news-engine/config', requireAdmin, async (_req, res) => {
const result = await fetchNewsEngine('/v1/config');
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
});
adminApi.get('/news-engine/collections', requireAdmin, async (req, res) => {
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
const result = await fetchNewsEngine(`/v1/collections?limit=${limit}`);
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
});
adminApi.get('/news-engine/collections/latest', requireAdmin, async (_req, res) => {
const result = await fetchNewsEngine('/v1/collections/latest');
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
});
adminApi.get('/news-engine/collections/:id', requireAdmin, async (req, res) => {
const result = await fetchNewsEngine(`/v1/collections/${encodeURIComponent(req.params.id)}`);
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
});
adminApi.post('/news-engine/collect', requireAdmin, async (req, res) => {
const result = await fetchNewsEngine('/v1/collect', { method: 'POST', body: req.body ?? {} });
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 采集失败' });
});
adminApi.post('/news-engine/collections/:id/score', requireAdmin, async (req, res) => {
const result = await fetchNewsEngine(`/v1/collections/${encodeURIComponent(req.params.id)}/score`, {
method: 'POST',
body: req.body ?? {},
});
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 评分失败' });
});
adminApi.post('/news-engine/rank', requireAdmin, async (req, res) => {
const result = await fetchNewsEngine('/v1/rank', { method: 'POST', body: req.body ?? {} });
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 排序失败' });
});
adminApi.get('/news-engine/runs', requireAdmin, async (req, res) => {
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 30));
const result = await fetchNewsEngine(`/v1/runs?limit=${limit}`);
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
});
adminApi.get('/news-engine/runs/:id', requireAdmin, async (req, res) => {
const result = await fetchNewsEngine(`/v1/runs/${encodeURIComponent(req.params.id)}`);
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
});
adminApi.get('/news-engine/scheduler/status', requireAdmin, async (_req, res) => {
const result = await fetchNewsEngine('/v1/scheduler/status');
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 不可用' });
});
adminApi.post('/news-engine/scheduler/trigger', requireAdmin, async (req, res) => {
const result = await fetchNewsEngine('/v1/scheduler/trigger', { method: 'POST', body: req.body ?? {} });
return res.status(result.ok ? 200 : result.status || 502).json(result.data ?? { message: 'News Engine 采集触发失败' });
});
adminApi.get('/orchestrator/config', requireAdmin, async (_req, res) => {
if (!orchestratorConfigService?.getAdminConfig) {
return res.status(503).json({ message: 'Orchestrator 配置服务未启用' });
+10
View File
@@ -41,6 +41,10 @@ export async function bootstrapAdminServices() {
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
const { createBillingAdminConfigService } = await importMemind('billing-admin-config.mjs');
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
const {
createImageMakeAdminConfigService,
ensureImageMakeAdminConfigSchema,
} = await importMemind('image-make-admin-config.mjs');
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
const { createWechatNewsMorningDraftService } = await importMemind('wechat-news-morning-draft.mjs');
@@ -105,7 +109,12 @@ export async function bootstrapAdminServices() {
apiSecret,
});
await ensureAssetGatewaySchema(pool);
await ensureImageMakeAdminConfigSchema(pool);
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
const imageMakeAdminConfigService = createImageMakeAdminConfigService(pool, {
env: process.env,
llmProviderService,
});
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
env: process.env,
});
@@ -197,6 +206,7 @@ export async function bootstrapAdminServices() {
userAuth,
llmProviderService,
assetGatewayConfigService,
imageMakeAdminConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
+2
View File
@@ -99,6 +99,7 @@ ready
userAuth,
llmProviderService,
assetGatewayConfigService,
imageMakeAdminConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
@@ -132,6 +133,7 @@ ready
userAuth,
llmProviderService,
assetGatewayConfigService,
imageMakeAdminConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
+34
View File
@@ -0,0 +1,34 @@
export function resolveNewsEngineBaseUrl(env = process.env) {
return String(env.MEMIND_NEWS_ENGINE_URL ?? 'http://127.0.0.1:8092').trim().replace(/\/+$/, '');
}
export async function fetchNewsEngine(path, {
env = process.env,
method = 'GET',
body = null,
fetchImpl = fetch,
} = {}) {
const baseUrl = resolveNewsEngineBaseUrl(env);
if (!baseUrl) {
return { ok: false, status: 503, data: { message: '未配置 MEMIND_NEWS_ENGINE_URL' } };
}
const token = String(env.MEMIND_NEWS_ENGINE_API_TOKEN ?? '').trim();
const response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers: {
accept: 'application/json',
...(body ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(Number(env.MEMIND_NEWS_ENGINE_TIMEOUT_MS ?? 120000) || 120000),
});
const text = await response.text().catch(() => '');
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { message: text.slice(0, 500) || `HTTP ${response.status}` };
}
return { ok: response.ok, status: response.status, data };
}
+7
View File
@@ -10,6 +10,7 @@ import { PoliciesPage } from './admin/pages/PoliciesPage';
import { MindSpacePage } from './admin/pages/MindSpacePage';
import { MemoryV2Page } from './admin/pages/MemoryV2Page';
import { MindSearchPage } from './admin/pages/MindSearchPage';
import { NewsEnginePage } from './admin/pages/NewsEnginePage';
import { ProvidersPage } from './admin/pages/ProvidersPage';
import { SkillsPage } from './admin/pages/SkillsPage';
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
@@ -19,6 +20,7 @@ import { UsersPage } from './admin/pages/UsersPage';
import { WechatPage } from './admin/pages/WechatPage';
import { CursorChannelPage } from './admin/pages/CursorChannelPage';
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
import { ImageMakeConfigPage } from './admin/pages/ImageMakeConfigPage';
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage';
import { SeoGeoAnalyticsPage } from './admin/pages/SeoGeoAnalyticsPage';
@@ -134,12 +136,14 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="analytics/seo-geo" element={<SeoGeoAnalyticsPage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="mindsearch" element={<MindSearchPage />} />
<Route path="news-engine" element={<NewsEnginePage />} />
<Route path="skill-runtime" element={<SkillRuntimePage />} />
<Route path="orchestrator" element={<OrchestratorPage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="cursor-channel" element={<CursorChannelPage />} />
<Route path="asset-gateway" element={<AssetGatewayPage />} />
<Route path="image-make" element={<ImageMakeConfigPage />} />
<Route path="blocked-words" element={<BlockedWordsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
@@ -187,11 +191,14 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/analytics')
|| pathname.startsWith('/memory-v2')
|| pathname.startsWith('/mindsearch')
|| pathname.startsWith('/news-engine')
|| pathname.startsWith('/skill-runtime')
|| pathname.startsWith('/orchestrator')
|| pathname.startsWith('/providers')
|| pathname.startsWith('/wechat')
|| pathname.startsWith('/asset-gateway')
|| pathname.startsWith('/image-make')
) {
return role === 'admin' || role === undefined ? pathname : '/ops';
}
+2
View File
@@ -37,6 +37,7 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/analytics/seo-geo', label: 'SEO / GEO 流量' },
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/mindsearch', label: 'MindSearch' },
{ to: '/news-engine', label: 'News Engine' },
{ to: '/skill-runtime', label: 'Skill Runtime' },
{ to: '/orchestrator', label: '任务编排' },
{ to: '/system-tests', label: '系统测试验证' },
@@ -45,6 +46,7 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/policies', label: '策略' },
{ to: '/providers', label: '统一模型中心' },
{ to: '/asset-gateway', label: '资产能力' },
{ to: '/image-make', label: '生图大模型' },
{ to: '/blocked-words', label: '违禁词管理' },
],
},
+304
View File
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useState } from 'react';
import {
getImageMakeAdminConfig,
getImageMakeRuntimeStatus,
updateImageMakeAdminConfig,
} from '../../api/client';
import type { ImageMakeAdminConfig } from '../../types';
import { formatTime } from '../utils/format';
const FAL_MODELS = ['fal-ai/flux/schnell', 'fal-ai/flux/dev'] as const;
export function ImageMakeConfigPage() {
const [config, setConfig] = useState<ImageMakeAdminConfig | null>(null);
const [source, setSource] = useState('');
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [runtimeOk, setRuntimeOk] = useState<boolean | null>(null);
const [falApiKey, setFalApiKey] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [admin, runtime] = await Promise.all([
getImageMakeAdminConfig(),
getImageMakeRuntimeStatus().catch(() => null),
]);
setConfig(admin.config);
setSource(admin.source);
setUpdatedAt(admin.updatedAt);
setRuntimeOk(runtime ? true : false);
setFalApiKey('');
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const patchConfig = (patch: Partial<ImageMakeAdminConfig>) => {
setConfig((current) => (current ? { ...current, ...patch } : current));
};
const patchFal = (patch: Partial<ImageMakeAdminConfig['providers']['fal_ai']>) => {
setConfig((current) => {
if (!current) return current;
return {
...current,
providers: {
...current.providers,
fal_ai: { ...current.providers.fal_ai, ...patch },
},
};
});
};
const save = async () => {
if (!config) return;
setBusy(true);
setError(null);
setMessage(null);
try {
const payload: Parameters<typeof updateImageMakeAdminConfig>[0] = {
defaultProvider: config.defaultProvider,
jobDefaultTimeoutSeconds: config.jobDefaultTimeoutSeconds,
providers: {
mock: { enabled: config.providers.mock.enabled },
aliyun_bailian: { enabled: config.providers.aliyun_bailian.enabled },
comfyui: { enabled: config.providers.comfyui.enabled },
fal_ai: {
enabled: config.providers.fal_ai.enabled,
model: config.providers.fal_ai.model,
apiBase: config.providers.fal_ai.apiBase,
numInferenceSteps: config.providers.fal_ai.numInferenceSteps,
},
},
};
if (falApiKey.trim()) {
payload.providers = {
...payload.providers,
fal_ai: {
...payload.providers?.fal_ai,
apiKey: falApiKey.trim(),
},
};
}
const saved = await updateImageMakeAdminConfig(payload);
setConfig(saved.config);
setSource(saved.source);
setUpdatedAt(saved.updatedAt);
setFalApiKey('');
setMessage('生图 Provider 配置已保存');
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setBusy(false);
}
};
const enableFalOnly = () => {
if (!config) return;
setConfig({
...config,
defaultProvider: 'fal_ai',
providers: {
...config.providers,
mock: { ...config.providers.mock, enabled: false },
aliyun_bailian: { ...config.providers.aliyun_bailian, enabled: false },
comfyui: { ...config.providers.comfyui, enabled: false },
fal_ai: {
...config.providers.fal_ai,
enabled: true,
model: config.providers.fal_ai.model || 'fal-ai/flux/schnell',
apiBase: config.providers.fal_ai.apiBase || 'https://queue.fal.run',
numInferenceSteps: config.providers.fal_ai.numInferenceSteps ?? 4,
},
},
});
};
if (loading) {
return (
<div className="admin-page">
<p className="muted"></p>
</div>
);
}
if (!config) {
return (
<div className="admin-page">
<p className="error-text">{error ?? '无法加载配置'}</p>
</div>
);
}
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted">
image_make Providerfal.ai 使 Queue API image_make Portal
</p>
</div>
{error && <div className="admin-alert error">{error}</div>}
{message && <div className="admin-alert success">{message}</div>}
<div className="admin-card">
<div className="admin-card-head">
<h3></h3>
</div>
<p className="muted">
{source || 'unknown'}
{updatedAt ? ` · 更新于 ${formatTime(updatedAt)}` : ''}
{runtimeOk === false ? ' · 运行时校验未通过(请检查 Key 与默认 Provider' : ''}
</p>
</div>
<div className="admin-card">
<div className="admin-card-head" style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<h3>fal.aiFlux Schnell / Dev</h3>
<button type="button" className="ghost-btn" onClick={enableFalOnly}>
fal.ai
</button>
</div>
<label className="admin-field">
<span> fal.ai</span>
<input
type="checkbox"
checked={config.providers.fal_ai.enabled}
onChange={(e) => patchFal({ enabled: e.target.checked })}
/>
</label>
<label className="admin-field">
<span> Provider</span>
<select
value={config.defaultProvider}
onChange={(e) => patchConfig({ defaultProvider: e.target.value })}
>
<option value="fal_ai">fal_ai</option>
<option value="mock">mock</option>
<option value="aliyun_bailian">aliyun_bailian</option>
<option value="comfyui">comfyui</option>
</select>
</label>
<label className="admin-field">
<span></span>
<select
value={config.providers.fal_ai.model ?? 'fal-ai/flux/schnell'}
onChange={(e) => patchFal({ model: e.target.value })}
>
{FAL_MODELS.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
</label>
<label className="admin-field">
<span>API Base</span>
<input
type="text"
value={config.providers.fal_ai.apiBase ?? 'https://queue.fal.run'}
onChange={(e) => patchFal({ apiBase: e.target.value })}
/>
</label>
<label className="admin-field">
<span>FAL API Key</span>
<input
type="password"
placeholder={config.providers.fal_ai.apiKeyConfigured
? `已配置 ${config.providers.fal_ai.apiKeyMasked ?? ''}`
: 'id:secret 格式'}
value={falApiKey}
onChange={(e) => setFalApiKey(e.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
type="number"
min={1}
max={12}
value={config.providers.fal_ai.numInferenceSteps ?? 4}
onChange={(e) => patchFal({ numInferenceSteps: Number(e.target.value) })}
/>
</label>
<label className="admin-field">
<span></span>
<input
type="number"
min={5}
max={1800}
value={config.jobDefaultTimeoutSeconds}
onChange={(e) => patchConfig({ jobDefaultTimeoutSeconds: Number(e.target.value) })}
/>
</label>
<div style={{ marginTop: 16 }}>
<button type="button" className="send-btn" disabled={busy} onClick={() => void save()}>
{busy ? '保存中…' : '保存配置'}
</button>
</div>
</div>
<div className="admin-card">
<div className="admin-card-head">
<h3> Provider</h3>
</div>
<p className="muted">使 fal.ai mock / / ComfyUI</p>
<label className="admin-field">
<span>mock</span>
<input
type="checkbox"
checked={config.providers.mock.enabled}
onChange={(e) => patchConfig({
providers: { ...config.providers, mock: { ...config.providers.mock, enabled: e.target.checked } },
})}
/>
</label>
<label className="admin-field">
<span> Qwen-Image</span>
<input
type="checkbox"
checked={config.providers.aliyun_bailian.enabled}
onChange={(e) => patchConfig({
providers: {
...config.providers,
aliyun_bailian: { ...config.providers.aliyun_bailian, enabled: e.target.checked },
},
})}
/>
</label>
<label className="admin-field">
<span>ComfyUI </span>
<input
type="checkbox"
checked={config.providers.comfyui.enabled}
onChange={(e) => patchConfig({
providers: {
...config.providers,
comfyui: { ...config.providers.comfyui, enabled: e.target.checked },
},
})}
/>
</label>
</div>
</div>
);
}
+382
View File
@@ -0,0 +1,382 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
collectNewsEngine,
getNewsEngineCollection,
getNewsEngineCollections,
getNewsEngineConfig,
getNewsEngineHealth,
getNewsEngineRuns,
getNewsEngineSchedulerStatus,
getWechatNewsMorningDraftTodayStatus,
rankNewsEngine,
scoreNewsEngineCollection,
triggerNewsEngineScheduler,
} from '../../api/client';
import type {
NewsEngineCollectionDetail,
NewsEngineCollectionSummary,
NewsEngineConfig,
NewsEngineRankResult,
NewsEngineRunSummary,
NewsEngineSchedulerStatus,
WechatNewsMorningDraftTodayStatus,
} from '../../types';
import { formatTime } from '../utils/format';
function statusBadge(ok: boolean | null) {
if (ok === null) return <span className="muted"></span>;
return ok
? <span style={{ color: 'var(--color-success, #0f766e)' }}>线</span>
: <span style={{ color: 'var(--color-danger, #c8362f)' }}>线</span>;
}
function formatInterval(ms: number) {
if (!ms) return '未启用';
if (ms % 3_600_000 === 0) return `${ms / 3_600_000} 小时`;
if (ms % 60_000 === 0) return `${ms / 60_000} 分钟`;
return `${Math.round(ms / 1000)}`;
}
export function NewsEnginePage() {
const [healthOk, setHealthOk] = useState<boolean | null>(null);
const [config, setConfig] = useState<NewsEngineConfig | null>(null);
const [scheduler, setScheduler] = useState<NewsEngineSchedulerStatus | null>(null);
const [runs, setRuns] = useState<NewsEngineRunSummary[]>([]);
const [collections, setCollections] = useState<NewsEngineCollectionSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<NewsEngineCollectionDetail | null>(null);
const [rankPreview, setRankPreview] = useState<NewsEngineRankResult | null>(null);
const [limit, setLimit] = useState(15);
const [minimumScore, setMinimumScore] = useState(0);
const [useLlm, setUseLlm] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [publishStatus, setPublishStatus] = useState<WechatNewsMorningDraftTodayStatus | null>(null);
const loadOverview = useCallback(async () => {
const [health, cfg, list, runList, schedulerStatus, todayStatus] = await Promise.all([
getNewsEngineHealth().then(() => true).catch(() => false),
getNewsEngineConfig().catch(() => null),
getNewsEngineCollections().catch(() => [] as NewsEngineCollectionSummary[]),
getNewsEngineRuns().catch(() => [] as NewsEngineRunSummary[]),
getNewsEngineSchedulerStatus().catch(() => null),
getWechatNewsMorningDraftTodayStatus().catch(() => null),
]);
setHealthOk(health);
setConfig(cfg);
setCollections(Array.isArray(list) ? list : []);
setRuns(Array.isArray(runList) ? runList : []);
setScheduler(schedulerStatus);
setPublishStatus(todayStatus);
if (cfg?.ranking) {
setLimit(cfg.ranking.dailyLimit);
setMinimumScore(cfg.ranking.minimumScore);
}
if (!selectedId && Array.isArray(list) && list.length) {
setSelectedId(list[0].id);
}
}, [selectedId]);
const loadDetail = useCallback(async (id: string) => {
const next = await getNewsEngineCollection(id);
setDetail(next);
setRankPreview(null);
}, []);
useEffect(() => {
void loadOverview().catch((error) => {
setMessage(error instanceof Error ? error.message : '加载 News Engine 失败');
});
}, [loadOverview]);
useEffect(() => {
if (!selectedId) {
setDetail(null);
return;
}
void loadDetail(selectedId).catch((error) => {
setMessage(error instanceof Error ? error.message : '加载采集详情失败');
});
}, [selectedId, loadDetail]);
const runCollect = async () => {
setBusy('collect');
setMessage('采集中,可能需要 1–2 分钟…');
try {
const bundle = await collectNewsEngine({ useLlm });
setMessage(`采集完成:${bundle.ranking?.selected?.length ?? 0} 条入选`);
await loadOverview();
if (bundle.collectionId) setSelectedId(bundle.collectionId);
} catch (error) {
setMessage(error instanceof Error ? error.message : '采集失败');
} finally {
setBusy(null);
}
};
const runScheduler = async () => {
setBusy('scheduler');
setMessage('调度采集中…');
try {
const bundle = await triggerNewsEngineScheduler({ useLlm });
setMessage(`调度采集完成:${bundle.ranking?.selected?.length ?? 0} 条入选`);
await loadOverview();
if (bundle.collectionId) setSelectedId(bundle.collectionId);
} catch (error) {
setMessage(error instanceof Error ? error.message : '调度采集失败');
} finally {
setBusy(null);
}
};
const runScore = async () => {
if (!selectedId) return;
setBusy('score');
setMessage('LLM 评分中…');
try {
const bundle = await scoreNewsEngineCollection(selectedId);
setDetail(bundle);
setMessage(`评分完成:${bundle.scoring?.assessedCount ?? 0} 条已评估`);
await loadOverview();
} catch (error) {
setMessage(error instanceof Error ? error.message : '评分失败');
} finally {
setBusy(null);
}
};
const runRankPreview = async () => {
if (!detail?.groups?.length) return;
setBusy('rank');
setMessage('排序预览中…');
try {
const result = await rankNewsEngine({
groups: detail.groups,
options: { limit, minimumScore },
});
setRankPreview(result);
setMessage(`预览完成:${result.selected?.length ?? 0} 条入选`);
} catch (error) {
setMessage(error instanceof Error ? error.message : '排序预览失败');
} finally {
setBusy(null);
}
};
const previewUrl = `${config?.publicBaseUrl ?? 'http://127.0.0.1:8092'}/news`;
const selectedArticles = detail?.ranking?.selected ?? rankPreview?.selected ?? [];
return (
<section className="admin-page">
<div className="admin-page-header">
<div>
<h1>News Engine</h1>
<p>Memind /LLM LLM </p>
</div>
<div className="wechat-toolbar">
{statusBadge(healthOk)}
<a className="ghost-btn" href={previewUrl} target="_blank" rel="noreferrer">
</a>
</div>
</div>
{message && <p className="banner">{message}</p>}
<div className="admin-card">
<h2></h2>
<div className="admin-form">
<label className="plan-form-row"><span>SearXNG</span><strong>{config?.searxngConfigured ? '已配置' : '未配置'}</strong></label>
<label className="plan-form-row"><span></span><strong>{config?.ranking?.dailyLimit ?? limit}</strong></label>
<label className="plan-form-row"><span></span><strong>{config?.ranking?.minimumScore ?? minimumScore}</strong></label>
<label className="plan-form-row"><span>LLM</span><strong>{config?.llm?.enabled ? `${config.llm.providerLabel ?? ''} · ${config.llm.model ?? ''}` : (config?.llm?.reason ?? '未启用')}</strong></label>
<label className="plan-form-row"><span>LLM </span><strong>{config?.llm?.source ?? '—'}</strong></label>
<label className="plan-form-row"><span></span><strong>{config?.llm?.maxEvents ?? '—'}</strong></label>
<label className="plan-form-row"><span></span><strong>{config?.media?.enabled ? `已启用 · ${config.media.publicBaseUrl}/media/` : '未启用'}</strong></label>
</div>
</div>
<div className="admin-card">
<h2></h2>
<div className="admin-form">
<label className="plan-form-row"><span></span><strong>{publishStatus?.todayPageReady ? '页面已就绪' : '尚未生成'}</strong></label>
<label className="plan-form-row"><span></span><strong>{publishStatus?.dateKey ?? '—'}</strong></label>
<label className="plan-form-row"><span> slug</span><strong>{publishStatus?.expectedSlug ?? '—'}</strong></label>
<label className="plan-form-row"><span></span><strong>{publishStatus?.autoGenerateEnabled ? '已开启' : '未开启'}</strong></label>
<label className="plan-form-row"><span></span><strong>{publishStatus?.autoPushEnabled ? '已开启' : '未开启'}</strong></label>
</div>
<div className="wechat-toolbar">
<Link className="ghost-btn" to="/wechat"></Link>
{publishStatus?.page?.url ? (
<a className="ghost-btn" href={publishStatus.page.url} target="_blank" rel="noreferrer"></a>
) : null}
</div>
<p className="muted" style={{ marginTop: 12 }}>
Memind worker SearXNG News Engine 使 `/media/`
</p>
</div>
<div className="admin-card">
<h2></h2>
<div className="admin-form">
<label className="plan-form-row"><span></span><strong>{scheduler?.enabled ? '已启用' : '未启用(需设置 MEMIND_NEWS_ENGINE_COLLECT_INTERVAL_MS ≥ 60000'}</strong></label>
<label className="plan-form-row"><span></span><strong>{formatInterval(scheduler?.intervalMs ?? 0)}</strong></label>
<label className="plan-form-row"><span> LLM</span><strong>{scheduler?.useLlm ? '是' : '否'}</strong></label>
<label className="plan-form-row"><span></span><strong>{scheduler?.lastRunAt ? formatTime(scheduler.lastRunAt) : '—'}</strong></label>
<label className="plan-form-row"><span></span><strong>{scheduler?.lastStatus ?? '—'}{scheduler?.lastError ? `${scheduler.lastError}` : ''}</strong></label>
<label className="plan-form-row"><span></span><strong>{scheduler?.nextRunAt ? formatTime(scheduler.nextRunAt) : '—'}</strong></label>
</div>
</div>
<div className="admin-card">
<h2></h2>
<div className="admin-form" style={{ marginBottom: 16 }}>
<label className="plan-form-row">
<span></span>
<input type="number" min={1} max={30} value={limit} onChange={(e) => setLimit(Number(e.target.value) || 15)} />
</label>
<label className="plan-form-row">
<span></span>
<input type="number" min={0} max={100} value={minimumScore} onChange={(e) => setMinimumScore(Number(e.target.value) || 0)} />
</label>
<label className="plan-form-row">
<span> LLM </span>
<input type="checkbox" checked={useLlm} onChange={(e) => setUseLlm(e.target.checked)} />
</label>
</div>
<div className="wechat-toolbar">
<button type="button" className="send-btn" disabled={!!busy} onClick={() => void runCollect()}>
{busy === 'collect' ? '采集中…' : '立即采集'}
</button>
<button type="button" className="ghost-btn" disabled={!!busy || scheduler?.running} onClick={() => void runScheduler()}>
{busy === 'scheduler' ? '调度中…' : '触发调度采集'}
</button>
<button type="button" className="ghost-btn" disabled={!!busy || !selectedId} onClick={() => void runScore()}>
{busy === 'score' ? '评分中…' : 'LLM 评分当前批次'}
</button>
<button type="button" className="ghost-btn" disabled={!!busy || !detail?.groups?.length} onClick={() => void runRankPreview()}>
{busy === 'rank' ? '预览中…' : '规则排序预览'}
</button>
<button type="button" className="ghost-btn" disabled={!!busy} onClick={() => void loadOverview()}>
</button>
</div>
</div>
<div className="admin-card">
<h2></h2>
{!runs.length ? (
<p className="muted"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{runs.map((row) => (
<tr key={row.id}>
<td>{formatTime(row.createdAt)}</td>
<td>{row.candidateCount}</td>
<td>{row.eventCount}</td>
<td>{row.selectedCount}</td>
<td>{String(row.options?.source ?? '—')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="admin-card">
<h2></h2>
{!collections.length ? (
<p className="muted"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{collections.map((row) => (
<tr key={row.id}>
<td>{formatTime(row.createdAt)}</td>
<td>{row.dateLabel || '—'}</td>
<td>{row.status}</td>
<td>{row.candidateCount}</td>
<td>{row.eventCount}</td>
<td>{row.selectedCount}</td>
<td>
<button type="button" className="ghost-btn" onClick={() => setSelectedId(row.id)}>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{detail && (
<div className="admin-card">
<h2> · {detail.dateLabel || detail.id.slice(0, 8)}</h2>
<p className="muted">
{detail.candidateCount} · {detail.eventCount} · {detail.selectedCount}
{detail.scoring?.assessedCount != null ? ` · LLM 已评 ${detail.scoring.assessedCount}` : ''}
</p>
{!selectedArticles.length ? (
<p className="muted"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{selectedArticles.map((item) => (
<tr key={item.id ?? item.url}>
<td>{item.canonicalTitle ?? item.title ?? item.primaryArticle?.title ?? '—'}</td>
<td>{item.category ?? item.groupTitle ?? '—'}</td>
<td>{item.finalScore != null ? item.finalScore.toFixed(1) : (item.score != null ? item.score.toFixed(1) : '—')}</td>
<td>
{item.primaryArticle?.image ? (
<a href={item.primaryArticle.image} target="_blank" rel="noreferrer"></a>
) : item.url ? (
<a href={item.url} target="_blank" rel="noreferrer"></a>
) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</section>
);
}
+87
View File
@@ -1,5 +1,7 @@
import type {
AssetGatewayConfig,
ImageMakeAdminConfig,
ImageMakeAdminConfigResponse,
AdminDashboardSummary,
AdminServiceRestartAction,
AdminServiceRestartResult,
@@ -70,6 +72,13 @@ import type {
WechatWebNotification,
MindSearchConfig,
MindSearchServiceTestResult,
NewsEngineCollectionDetail,
NewsEngineCollectionSummary,
NewsEngineConfig,
NewsEngineHealth,
NewsEngineRankResult,
NewsEngineRunSummary,
NewsEngineSchedulerStatus,
AdminTemplateCatalogItem,
AdminTemplateCatalogPatch,
} from '../types';
@@ -555,6 +564,35 @@ export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
return portalFetch('/admin-api/asset-gateway/config');
}
export async function getImageMakeAdminConfig(): Promise<ImageMakeAdminConfigResponse> {
return portalFetch('/admin-api/image-make/config');
}
export async function updateImageMakeAdminConfig(
payload: Partial<ImageMakeAdminConfig> & {
providers?: Partial<ImageMakeAdminConfig['providers']> & {
fal_ai?: Partial<ImageMakeAdminConfig['providers']['fal_ai']> & { apiKey?: string };
aliyun_bailian?: Partial<ImageMakeAdminConfig['providers']['aliyun_bailian']> & { apiKey?: string };
};
},
): Promise<ImageMakeAdminConfigResponse> {
return portalFetch('/admin-api/image-make/config', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function getImageMakeRuntimeStatus(): Promise<{
ok: boolean;
defaultProvider: string;
fingerprint?: string;
source?: string;
updatedAt?: number | null;
providers: ImageMakeAdminConfig['providers'];
}> {
return portalFetch('/admin-api/image-make/runtime');
}
export async function updateAssetGatewayConfig(
payload: Pick<AssetGatewayConfig, 'enabled'>,
): Promise<AssetGatewayConfig> {
@@ -1054,6 +1092,55 @@ export async function testMindSearchService(serviceId: string): Promise<MindSear
return portalFetch(`/admin-api/mindsearch/services/${encodeURIComponent(serviceId)}/test`, { method: 'POST' });
}
export async function getNewsEngineHealth(): Promise<NewsEngineHealth> {
return portalFetch('/admin-api/news-engine/health');
}
export async function getNewsEngineConfig(): Promise<NewsEngineConfig> {
return portalFetch('/admin-api/news-engine/config');
}
export async function getNewsEngineCollections(limit = 20): Promise<NewsEngineCollectionSummary[]> {
return portalFetch(`/admin-api/news-engine/collections?limit=${limit}`);
}
export async function getNewsEngineCollection(id: string): Promise<NewsEngineCollectionDetail> {
return portalFetch(`/admin-api/news-engine/collections/${encodeURIComponent(id)}`);
}
export async function collectNewsEngine(body: { useLlm?: boolean; groupIds?: string[] } = {}): Promise<NewsEngineCollectionDetail> {
return portalFetch('/admin-api/news-engine/collect', { method: 'POST', body: JSON.stringify(body) });
}
export async function scoreNewsEngineCollection(id: string, body: Record<string, unknown> = {}): Promise<NewsEngineCollectionDetail> {
return portalFetch(`/admin-api/news-engine/collections/${encodeURIComponent(id)}/score`, {
method: 'POST',
body: JSON.stringify(body),
});
}
export async function rankNewsEngine(body: {
groups: unknown[];
options?: { limit?: number; minimumScore?: number };
}): Promise<NewsEngineRankResult> {
return portalFetch('/admin-api/news-engine/rank', { method: 'POST', body: JSON.stringify(body) });
}
export async function getNewsEngineRuns(limit = 30): Promise<NewsEngineRunSummary[]> {
return portalFetch(`/admin-api/news-engine/runs?limit=${limit}`);
}
export async function getNewsEngineSchedulerStatus(): Promise<NewsEngineSchedulerStatus> {
return portalFetch('/admin-api/news-engine/scheduler/status');
}
export async function triggerNewsEngineScheduler(body: { useLlm?: boolean } = {}): Promise<NewsEngineCollectionDetail> {
return portalFetch('/admin-api/news-engine/scheduler/trigger', {
method: 'POST',
body: JSON.stringify(body),
});
}
// ── Workflow Orchestrator ─────────────────────────────
export async function getOrchestratorConfig() {
+139
View File
@@ -233,6 +233,38 @@ export type AssetGatewayConfig = {
plugins: AssetPluginConfig[];
};
export type ImageMakeProviderConfig = {
enabled: boolean;
model?: string;
apiBase?: string;
apiKeyConfigured?: boolean;
apiKeyMasked?: string;
numInferenceSteps?: number;
checkpoint?: string;
workflowPath?: string;
};
export type ImageMakeAdminConfig = {
defaultProvider: string;
jobDefaultTimeoutSeconds: number;
providers: {
mock: ImageMakeProviderConfig;
aliyun_bailian: ImageMakeProviderConfig & {
dashScopeSource?: string | null;
dashScopeKeyName?: string | null;
};
comfyui: ImageMakeProviderConfig;
fal_ai: ImageMakeProviderConfig;
};
};
export type ImageMakeAdminConfigResponse = {
config: ImageMakeAdminConfig;
source: string;
updatedAt: number | null;
updatedBy: string | null;
};
export type AdminServiceRestartAction = 'local_restart' | 'pro_restart';
export type AdminServiceRestartResult = {
@@ -930,6 +962,113 @@ export type MindSearchRoutes = {
research: string;
};
export type NewsEngineHealth = {
ok: boolean;
service?: string;
};
export type NewsEngineSchedulerStatus = {
enabled: boolean;
intervalMs: number;
useLlm: boolean;
running: boolean;
lastRunAt: number | null;
lastStatus: string | null;
lastError: string | null;
lastCollectionId: string | null;
lastSelectedCount: number | null;
lastSource: string | null;
nextRunAt: number | null;
reason?: string | null;
};
export type NewsEngineConfig = {
service?: string;
publicBaseUrl?: string;
ranking?: {
dailyLimit: number;
minimumScore: number;
};
collect?: {
limit: number;
concurrency: number;
timezone: string;
engines: string;
};
scheduler?: Pick<NewsEngineSchedulerStatus, 'enabled' | 'intervalMs' | 'useLlm'>;
media?: {
enabled: boolean;
root: string;
publicBaseUrl: string;
};
llm: {
enabled: boolean;
model: string | null;
keyName: string | null;
providerLabel: string | null;
source: string | null;
maxEvents: number | null;
reason?: string | null;
};
searxngConfigured: boolean;
searxngEndpoint?: string | null;
};
export type NewsEngineRunSummary = {
id: string;
createdAt: number;
candidateCount: number;
eventCount: number;
selectedCount: number;
options?: Record<string, unknown>;
};
export type NewsEngineCollectionSummary = {
id: string;
createdAt: number;
dateLabel: string;
endpoint: string;
status: string;
candidateCount: number;
eventCount: number;
selectedCount: number;
articleCount: number;
error: string | null;
};
export type NewsEngineSelectedItem = {
id?: string;
title?: string;
canonicalTitle?: string;
url?: string;
score?: number;
finalScore?: number;
category?: string;
groupTitle?: string;
primaryArticle?: {
title?: string;
url?: string;
image?: string;
sourceDomain?: string;
};
};
export type NewsEngineRankResult = {
candidateCount?: number;
eventCount?: number;
selected?: NewsEngineSelectedItem[];
runId?: string | null;
};
export type NewsEngineCollectionDetail = NewsEngineCollectionSummary & {
groups?: Array<{ id?: string; title?: string; results?: unknown[] }>;
ranking?: NewsEngineRankResult;
scoring?: { assessedCount?: number; skipped?: boolean; reason?: string | null };
collectionId?: string;
articles?: unknown[];
assessments?: unknown[];
};
export type MindSearchServiceTestResult = {
ok: boolean;
serviceId: string;