diff --git a/.env.example b/.env.example index 4a8a75e..7718198 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/server/app.mjs b/server/app.mjs index fc80d07..c83ec06 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -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 配置服务未启用' }); diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index 8c03555..3615bc5 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -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, diff --git a/server/index.mjs b/server/index.mjs index 28ce275..d813d32 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -99,6 +99,7 @@ ready userAuth, llmProviderService, assetGatewayConfigService, + imageMakeAdminConfigService, plazaOps, createOpsApi, wechatAdmin, @@ -132,6 +133,7 @@ ready userAuth, llmProviderService, assetGatewayConfigService, + imageMakeAdminConfigService, plazaOps, createOpsApi, wechatAdmin, diff --git a/server/news-engine-admin.mjs b/server/news-engine-admin.mjs new file mode 100644 index 0000000..154ebee --- /dev/null +++ b/server/news-engine-admin.mjs @@ -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 }; +} diff --git a/src/App.tsx b/src/App.tsx index 5fbdadd..f834c0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 } } /> } /> } /> + } /> } /> } /> } /> } /> } /> } /> + } /> } /> } /> @@ -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'; } diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 12ce1a9..bb4de24 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -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: '违禁词管理' }, ], }, diff --git a/src/admin/pages/ImageMakeConfigPage.tsx b/src/admin/pages/ImageMakeConfigPage.tsx new file mode 100644 index 0000000..fc9c86e --- /dev/null +++ b/src/admin/pages/ImageMakeConfigPage.tsx @@ -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(null); + const [source, setSource] = useState(''); + const [updatedAt, setUpdatedAt] = useState(null); + const [runtimeOk, setRuntimeOk] = useState(null); + const [falApiKey, setFalApiKey] = useState(''); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(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) => { + setConfig((current) => (current ? { ...current, ...patch } : current)); + }; + + const patchFal = (patch: Partial) => { + 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[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 ( +
+

加载生图配置…

+
+ ); + } + + if (!config) { + return ( +
+

{error ?? '无法加载配置'}

+
+ ); + } + + return ( +
+
+

生图大模型

+

+ 配置 image_make 服务的 Provider。fal.ai 使用 Queue API;保存后 image_make 会通过 Portal 运行时配置拉取。 +

+
+ + {error &&
{error}
} + {message &&
{message}
} + +
+
+

运行状态

+
+

+ 配置来源:{source || 'unknown'} + {updatedAt ? ` · 更新于 ${formatTime(updatedAt)}` : ''} + {runtimeOk === false ? ' · 运行时校验未通过(请检查 Key 与默认 Provider)' : ''} +

+
+ +
+
+

fal.ai(Flux Schnell / Dev)

+ +
+ + + + + + + + + + + + + + + +
+ +
+
+ +
+
+

其它 Provider(开关)

+
+

生产环境若仅使用 fal.ai,建议关闭 mock / 百炼 / ComfyUI。

+ + + +
+
+ ); +} diff --git a/src/admin/pages/NewsEnginePage.tsx b/src/admin/pages/NewsEnginePage.tsx new file mode 100644 index 0000000..301aede --- /dev/null +++ b/src/admin/pages/NewsEnginePage.tsx @@ -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 检测中…; + return ok + ? 在线 + : 离线; +} + +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(null); + const [config, setConfig] = useState(null); + const [scheduler, setScheduler] = useState(null); + const [runs, setRuns] = useState([]); + const [collections, setCollections] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [rankPreview, setRankPreview] = useState(null); + const [limit, setLimit] = useState(15); + const [minimumScore, setMinimumScore] = useState(0); + const [useLlm, setUseLlm] = useState(true); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(null); + const [publishStatus, setPublishStatus] = useState(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 ( +
+
+
+

News Engine

+

Memind 早报采集、质量过滤、规则/LLM 排序与发布前预览。服务独立运行,LLM 复用统一模型中心默认配置。

+
+
+ {statusBadge(healthOk)} + + 打开新闻预览页 + +
+
+ + {message &&

{message}

} + +
+

运行配置

+
+ + + + + + + +
+
+ +
+

发布联动

+
+ + + + + +
+
+ 前往服务号早报配置 + {publishStatus?.page?.url ? ( + 打开今日页面 + ) : null} +
+

+ Memind 早报 worker 会同时合并 SearXNG 全栏目预取与 News Engine 今日批次,再统一排序并优先使用本地 `/media/` 配图。发布前请确认今日采集批次与排序结果符合预期。 +

+
+ +
+

定时采集

+
+ + + + + + +
+
+ +
+

采集与排序

+
+ + + +
+
+ + + + + +
+
+ +
+

排序运行记录

+ {!runs.length ? ( +

暂无排序运行记录。

+ ) : ( +
+ + + + + + + + + + + + {runs.map((row) => ( + + + + + + + + ))} + +
时间候选事件入选来源
{formatTime(row.createdAt)}{row.candidateCount}{row.eventCount}{row.selectedCount}{String(row.options?.source ?? '—')}
+
+ )} +
+ +
+

采集历史

+ {!collections.length ? ( +

暂无采集记录。点击「立即采集」开始。

+ ) : ( +
+ + + + + + + + + + + + + + {collections.map((row) => ( + + + + + + + + + + ))} + +
时间日期标签状态候选事件入选操作
{formatTime(row.createdAt)}{row.dateLabel || '—'}{row.status}{row.candidateCount}{row.eventCount}{row.selectedCount} + +
+
+ )} +
+ + {detail && ( +
+

当前批次 · {detail.dateLabel || detail.id.slice(0, 8)}

+

+ 候选 {detail.candidateCount} · 事件 {detail.eventCount} · 入选 {detail.selectedCount} + {detail.scoring?.assessedCount != null ? ` · LLM 已评 ${detail.scoring.assessedCount}` : ''} +

+ {!selectedArticles.length ? ( +

暂无入选条目。

+ ) : ( +
+ + + + + + + + + + + {selectedArticles.map((item) => ( + + + + + + + ))} + +
标题栏目分数链接
{item.canonicalTitle ?? item.title ?? item.primaryArticle?.title ?? '—'}{item.category ?? item.groupTitle ?? '—'}{item.finalScore != null ? item.finalScore.toFixed(1) : (item.score != null ? item.score.toFixed(1) : '—')} + {item.primaryArticle?.image ? ( + 配图 + ) : item.url ? ( + 打开 + ) : '—'} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/api/client.ts b/src/api/client.ts index a7c8e03..f6ff01c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 { return portalFetch('/admin-api/asset-gateway/config'); } +export async function getImageMakeAdminConfig(): Promise { + return portalFetch('/admin-api/image-make/config'); +} + +export async function updateImageMakeAdminConfig( + payload: Partial & { + providers?: Partial & { + fal_ai?: Partial & { apiKey?: string }; + aliyun_bailian?: Partial & { apiKey?: string }; + }; + }, +): Promise { + 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, ): Promise { @@ -1054,6 +1092,55 @@ export async function testMindSearchService(serviceId: string): Promise { + return portalFetch('/admin-api/news-engine/health'); +} + +export async function getNewsEngineConfig(): Promise { + return portalFetch('/admin-api/news-engine/config'); +} + +export async function getNewsEngineCollections(limit = 20): Promise { + return portalFetch(`/admin-api/news-engine/collections?limit=${limit}`); +} + +export async function getNewsEngineCollection(id: string): Promise { + return portalFetch(`/admin-api/news-engine/collections/${encodeURIComponent(id)}`); +} + +export async function collectNewsEngine(body: { useLlm?: boolean; groupIds?: string[] } = {}): Promise { + return portalFetch('/admin-api/news-engine/collect', { method: 'POST', body: JSON.stringify(body) }); +} + +export async function scoreNewsEngineCollection(id: string, body: Record = {}): Promise { + 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 { + return portalFetch('/admin-api/news-engine/rank', { method: 'POST', body: JSON.stringify(body) }); +} + +export async function getNewsEngineRuns(limit = 30): Promise { + return portalFetch(`/admin-api/news-engine/runs?limit=${limit}`); +} + +export async function getNewsEngineSchedulerStatus(): Promise { + return portalFetch('/admin-api/news-engine/scheduler/status'); +} + +export async function triggerNewsEngineScheduler(body: { useLlm?: boolean } = {}): Promise { + return portalFetch('/admin-api/news-engine/scheduler/trigger', { + method: 'POST', + body: JSON.stringify(body), + }); +} + // ── Workflow Orchestrator ───────────────────────────── export async function getOrchestratorConfig() { diff --git a/src/types.ts b/src/types.ts index 944b902..a36649c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; + 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; +}; + +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;