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:
@@ -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
@@ -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 配置服务未启用' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -99,6 +99,7 @@ ready
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
@@ -132,6 +133,7 @@ ready
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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: '违禁词管理' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 服务的 Provider。fal.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.ai(Flux 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user