Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ec4dda6af | |||
| eba37640e4 | |||
| 799afd1ea5 | |||
| 04d68d0b2c | |||
| 7c6427e123 | |||
| 8651a50cbe | |||
| b414b732c8 | |||
| d4127bd24b |
@@ -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
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ rsync -a \
|
||||
--include '*.mjs' \
|
||||
--include '*.json' \
|
||||
--include '*.js' \
|
||||
--include 'wechat/assets/*.png' \
|
||||
--exclude '*' \
|
||||
"${MEMIND_SRC}/" "${MEMIND_LIB}/"
|
||||
|
||||
|
||||
+211
@@ -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,
|
||||
@@ -108,6 +110,7 @@ export function createAdminApp(services) {
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatNewsMorningDraftService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatCursorExecutorPolicyService,
|
||||
adminSystemTestService,
|
||||
@@ -260,6 +263,7 @@ export function createAdminApp(services) {
|
||||
const { listAdminSeoGeoPublicationCatalog } = await importMemind('mindspace-seo-geo-admin-catalog.mjs');
|
||||
const startAt = Number(req.query.startAt);
|
||||
const endAt = Number(req.query.endAt);
|
||||
const config = loadMindSpaceConfig ? await loadMindSpaceConfig(pool) : null;
|
||||
const result = await listAdminSeoGeoPublicationCatalog(pool, {
|
||||
page: Number(req.query.page) || 1,
|
||||
pageSize: Number(req.query.pageSize) || 20,
|
||||
@@ -269,6 +273,8 @@ export function createAdminApp(services) {
|
||||
endAt: Number.isFinite(endAt) ? endAt : null,
|
||||
search: typeof req.query.search === 'string' ? req.query.search.trim() : '',
|
||||
publicHost: process.env.H5_PUBLIC_BASE_URL?.replace(/^https?:\/\//, '').replace(/\/$/, '') || 'm.tkmind.cn',
|
||||
env: process.env,
|
||||
analyticsConfig: config?.analytics ?? {},
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
@@ -420,6 +426,88 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/config', requireAdmin, async (_req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
const [config, template] = await Promise.all([
|
||||
wechatNewsMorningDraftService.getConfig(),
|
||||
Promise.resolve(wechatNewsMorningDraftService.getTemplate()),
|
||||
]);
|
||||
return res.json({ config, template });
|
||||
});
|
||||
|
||||
adminApi.patch('/wechat/news-morning-draft/config', requireAdmin, async (req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
const config = await wechatNewsMorningDraftService.updateConfig(req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
return res.json({ config });
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/preview', requireAdmin, async (_req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
try {
|
||||
return res.json(await wechatNewsMorningDraftService.preview());
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error instanceof Error ? error.message : '预览失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/today', requireAdmin, async (_req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
return res.json(await wechatNewsMorningDraftService.getTodayStatus());
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/preview/today', requireAdmin, async (_req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
try {
|
||||
return res.json(await wechatNewsMorningDraftService.previewToday());
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error instanceof Error ? error.message : '预览失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.post('/wechat/news-morning-draft/push', requireAdmin, async (req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
try {
|
||||
const result = await wechatNewsMorningDraftService.pushDraft({
|
||||
triggeredBy: req.currentUser.id,
|
||||
dryRun: req.body?.dryRun === true,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error instanceof Error ? error.message : '推送失败',
|
||||
run: error?.run ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/news-morning-draft/runs', requireAdmin, async (req, res) => {
|
||||
if (!wechatNewsMorningDraftService) {
|
||||
return res.status(503).json({ message: '新闻早报草稿推送未启用' });
|
||||
}
|
||||
const runs = await wechatNewsMorningDraftService.listRuns({
|
||||
limit: req.query.limit,
|
||||
});
|
||||
return res.json({ runs });
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/intent-router/config', requireAdmin, async (_req, res) => {
|
||||
if (!wechatIntentRouterConfigService?.getConfig) {
|
||||
return res.status(503).json({ message: '微信意图路由配置未启用' });
|
||||
@@ -538,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());
|
||||
@@ -588,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,8 +41,13 @@ 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');
|
||||
const { createWechatIntentRouterConfigService } = await importMemind('wechat-intent-router-config.mjs');
|
||||
const { createWechatCursorExecutorAdminConfigService } = await importMemind(
|
||||
'wechat-cursor-executor-admin-config.mjs',
|
||||
@@ -104,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,
|
||||
});
|
||||
@@ -138,6 +148,12 @@ export async function bootstrapAdminServices() {
|
||||
portalBaseUrl: `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
|
||||
});
|
||||
const wechatMpConfig = loadWechatMpConfig();
|
||||
const wechatNewsMorningDraftService = createWechatNewsMorningDraftService(pool, {
|
||||
mpConfig: wechatMpConfig,
|
||||
h5Root,
|
||||
memindLibRoot: resolveMemindLib(),
|
||||
env: process.env,
|
||||
});
|
||||
const wechatMpService = createWechatMpService({
|
||||
config: wechatMpConfig,
|
||||
userAuth,
|
||||
@@ -190,6 +206,7 @@ export async function bootstrapAdminServices() {
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
@@ -204,6 +221,7 @@ export async function bootstrapAdminServices() {
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatNewsMorningDraftService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatCursorExecutorPolicyService,
|
||||
adminSystemTestService,
|
||||
|
||||
@@ -99,6 +99,7 @@ ready
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
@@ -112,6 +113,7 @@ ready
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatNewsMorningDraftService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatCursorExecutorPolicyService,
|
||||
adminSystemTestService,
|
||||
@@ -131,6 +133,7 @@ ready
|
||||
userAuth,
|
||||
llmProviderService,
|
||||
assetGatewayConfigService,
|
||||
imageMakeAdminConfigService,
|
||||
plazaOps,
|
||||
createOpsApi,
|
||||
wechatAdmin,
|
||||
@@ -144,6 +147,7 @@ ready
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
wechatNewsMorningDraftService,
|
||||
wechatIntentRouterConfigService,
|
||||
wechatCursorExecutorPolicyService,
|
||||
adminSystemTestService,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,8 @@ export function SeoGeoAnalyticsPage() {
|
||||
const [startDate, setStartDate] = useState(initialRange.startDate);
|
||||
const [endDate, setEndDate] = useState(initialRange.endDate);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<PageSort>('totalViews');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [sortBy, setSortBy] = useState<PageSort>('publishedAt');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [rows, setRows] = useState<SeoGeoCatalogRow[]>([]);
|
||||
@@ -68,6 +69,7 @@ export function SeoGeoAnalyticsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openingUmami, setOpeningUmami] = useState(false);
|
||||
const [umamiStatus, setUmamiStatus] = useState<{ available: boolean; reason: string } | null>(null);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / 20));
|
||||
|
||||
@@ -93,11 +95,13 @@ export function SeoGeoAnalyticsPage() {
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
});
|
||||
setUmamiStatus(result.umami ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 SEO/GEO 页面目录失败');
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setTotals({ totalViews: 0, seoViews: 0, geoViews: 0, crawlerViews: 0 });
|
||||
setUmamiStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -107,6 +111,13 @@ export function SeoGeoAnalyticsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setSearch(searchInput.trim());
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [startDate, endDate, search, sortBy, sortOrder]);
|
||||
@@ -127,7 +138,12 @@ export function SeoGeoAnalyticsPage() {
|
||||
<div className="admin-page-head">
|
||||
<h2>SEO / GEO 流量</h2>
|
||||
<p className="muted">
|
||||
列出全部在线页(含零流量页面),仅展示统计与 URL,不提供跳转访问。可收录 = 公开且未过期。SEO / GEO 来源按 referrer 域名归类;爬虫按 User-Agent 识别。
|
||||
列出全部在线页(含零流量页面),仅展示统计与 URL,不提供跳转访问。可收录 = 公开且未过期。筛选期内 SEO / GEO 优先合并 Umami 埋点(MindSpace 直链);/u/ 发布链路与爬虫仍保留 MySQL 计数。
|
||||
{umamiStatus && !umamiStatus.available && umamiStatus.reason
|
||||
? ` 当前 Umami 未接入(${umamiStatus.reason}),MindSpace 直链流量可能偏低。`
|
||||
: umamiStatus?.available
|
||||
? ' Umami 已接入。'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -156,20 +172,20 @@ export function SeoGeoAnalyticsPage() {
|
||||
<span>搜索标题 / 用户 / URL</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder="例如 john、sitemap、问卷"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
value={searchInput}
|
||||
placeholder="例如 john、daily-news、0917"
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-form-row">
|
||||
<span>排序字段</span>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as PageSort)}>
|
||||
<option value="publishedAt">发布时间</option>
|
||||
<option value="totalViews">筛选期总访问</option>
|
||||
<option value="seoViews">SEO 搜索来源</option>
|
||||
<option value="geoViews">GEO 来源</option>
|
||||
<option value="crawlerViews">爬虫访问</option>
|
||||
<option value="lifetimeViewCount">累计访问</option>
|
||||
<option value="publishedAt">发布时间</option>
|
||||
<option value="pageTitle">标题</option>
|
||||
<option value="ownerLabel">创建用户</option>
|
||||
</select>
|
||||
|
||||
@@ -7,14 +7,20 @@ import {
|
||||
getWechatAdminSummary,
|
||||
getWechatIntentRouterConfig,
|
||||
getWechatIntentRouterRuntime,
|
||||
getWechatNewsMorningDraftConfig,
|
||||
getWechatNewsMorningDraftTodayStatus,
|
||||
listAdminUsers,
|
||||
listLlmProviderKeys,
|
||||
listWechatBindings,
|
||||
listWechatDeliveries,
|
||||
listWechatDigests,
|
||||
listWechatMessages,
|
||||
listWechatNewsMorningDraftRuns,
|
||||
listWechatWebNotifications,
|
||||
patchWechatIntentRouterConfig,
|
||||
patchWechatNewsMorningDraftConfig,
|
||||
previewWechatNewsMorningDraftToday,
|
||||
pushWechatNewsMorningDraft,
|
||||
resumeWechatDigest,
|
||||
updateWechatScheduleLlmConfig,
|
||||
} from '../../api/client';
|
||||
@@ -28,6 +34,10 @@ import type {
|
||||
WechatIntentRouterAdminConfig,
|
||||
WechatIntentRouterRuntimeState,
|
||||
WechatMessage,
|
||||
WechatNewsMorningDraftPreview,
|
||||
WechatNewsMorningDraftRun,
|
||||
WechatNewsMorningDraftTemplate,
|
||||
WechatNewsMorningDraftTodayStatus,
|
||||
WechatWebNotification,
|
||||
} from '../../types';
|
||||
import { formatTime } from '../utils/format';
|
||||
@@ -88,6 +98,35 @@ type IntentRouterForm = {
|
||||
canaryOpenids: string;
|
||||
};
|
||||
|
||||
type NewsMorningDraftForm = {
|
||||
enabled: boolean;
|
||||
autoPushEnabled: boolean;
|
||||
pushHour: string;
|
||||
pushMinute: string;
|
||||
timezone: string;
|
||||
sourceUserId: string;
|
||||
pageSlugPattern: string;
|
||||
author: string;
|
||||
templateVersion: string;
|
||||
};
|
||||
|
||||
function newsMorningDraftToForm(
|
||||
config: Partial<NewsMorningDraftForm> | undefined,
|
||||
template: WechatNewsMorningDraftTemplate | undefined,
|
||||
): NewsMorningDraftForm {
|
||||
return {
|
||||
enabled: Boolean(config?.enabled),
|
||||
autoPushEnabled: Boolean(config?.autoPushEnabled),
|
||||
pushHour: String(config?.pushHour ?? 6),
|
||||
pushMinute: String(config?.pushMinute ?? 0),
|
||||
timezone: String(config?.timezone ?? 'Asia/Shanghai'),
|
||||
sourceUserId: String(config?.sourceUserId ?? ''),
|
||||
pageSlugPattern: String(config?.pageSlugPattern ?? 'daily-news-*'),
|
||||
author: String(config?.author ?? 'TKMind'),
|
||||
templateVersion: String(config?.templateVersion ?? template?.version ?? '2026-09-10'),
|
||||
};
|
||||
}
|
||||
|
||||
function intentRouterToForm(
|
||||
config: WechatIntentRouterAdminConfig | undefined,
|
||||
keys: LlmProviderKeyRow[],
|
||||
@@ -147,6 +186,14 @@ export function WechatPage() {
|
||||
const [savedIntentForm, setSavedIntentForm] = useState<IntentRouterForm | null>(null);
|
||||
const [intentRuntime, setIntentRuntime] = useState<WechatIntentRouterRuntimeState | null>(null);
|
||||
const [intentSaving, setIntentSaving] = useState(false);
|
||||
const [newsDraftForm, setNewsDraftForm] = useState<NewsMorningDraftForm | null>(null);
|
||||
const [savedNewsDraftForm, setSavedNewsDraftForm] = useState<NewsMorningDraftForm | null>(null);
|
||||
const [newsDraftTemplate, setNewsDraftTemplate] = useState<WechatNewsMorningDraftTemplate | null>(null);
|
||||
const [newsDraftPreview, setNewsDraftPreview] = useState<WechatNewsMorningDraftPreview | null>(null);
|
||||
const [newsDraftTodayStatus, setNewsDraftTodayStatus] = useState<WechatNewsMorningDraftTodayStatus | null>(null);
|
||||
const [newsDraftRuns, setNewsDraftRuns] = useState<WechatNewsMorningDraftRun[]>([]);
|
||||
const [newsDraftSaving, setNewsDraftSaving] = useState(false);
|
||||
const [newsDraftWorking, setNewsDraftWorking] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -158,6 +205,11 @@ export function WechatPage() {
|
||||
return JSON.stringify(intentForm) !== JSON.stringify(savedIntentForm);
|
||||
}, [intentForm, savedIntentForm]);
|
||||
|
||||
const newsDraftDirty = useMemo(() => {
|
||||
if (!newsDraftForm || !savedNewsDraftForm) return false;
|
||||
return JSON.stringify(newsDraftForm) !== JSON.stringify(savedNewsDraftForm);
|
||||
}, [newsDraftForm, savedNewsDraftForm]);
|
||||
|
||||
const selectedIntentKey = useMemo(
|
||||
() => llmKeys.find((item) => item.id === intentForm?.modelProviderKeyId) ?? null,
|
||||
[llmKeys, intentForm?.modelProviderKeyId],
|
||||
@@ -178,6 +230,8 @@ export function WechatPage() {
|
||||
nextLlmKeys,
|
||||
nextIntentConfig,
|
||||
nextIntentRuntime,
|
||||
nextNewsDraftConfig,
|
||||
nextNewsDraftRuns,
|
||||
] =
|
||||
await Promise.all([
|
||||
getWechatAdminSummary(),
|
||||
@@ -190,6 +244,8 @@ export function WechatPage() {
|
||||
listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]),
|
||||
getWechatIntentRouterConfig().catch(() => null),
|
||||
getWechatIntentRouterRuntime().catch(() => null),
|
||||
getWechatNewsMorningDraftConfig().catch(() => null),
|
||||
listWechatNewsMorningDraftRuns(10).catch(() => [] as WechatNewsMorningDraftRun[]),
|
||||
]);
|
||||
setSummary(nextSummary);
|
||||
setScheduleLlmEnabled(nextSummary.config.scheduleLlmEnabled ?? false);
|
||||
@@ -207,6 +263,13 @@ export function WechatPage() {
|
||||
setSavedIntentForm(nextForm);
|
||||
}
|
||||
setIntentRuntime(nextIntentRuntime);
|
||||
if (nextNewsDraftConfig) {
|
||||
const nextForm = newsMorningDraftToForm(nextNewsDraftConfig.config, nextNewsDraftConfig.template);
|
||||
setNewsDraftForm(nextForm);
|
||||
setSavedNewsDraftForm(nextForm);
|
||||
setNewsDraftTemplate(nextNewsDraftConfig.template);
|
||||
}
|
||||
setNewsDraftRuns(nextNewsDraftRuns);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载服务号管理失败');
|
||||
} finally {
|
||||
@@ -214,10 +277,40 @@ export function WechatPage() {
|
||||
}
|
||||
}, [deliveryStatus, digestStatus, messageStatus, notificationStatus, search]);
|
||||
|
||||
const refreshTodayNewsMorningDraft = useCallback(async (sourceUserId?: string) => {
|
||||
if (!sourceUserId?.trim()) {
|
||||
setNewsDraftTodayStatus(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getWechatNewsMorningDraftTodayStatus();
|
||||
setNewsDraftTodayStatus(status);
|
||||
if (status.todayPageReady) {
|
||||
setNewsDraftPreview(await previewWechatNewsMorningDraftToday());
|
||||
} else {
|
||||
setNewsDraftPreview(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setNewsDraftTodayStatus(null);
|
||||
if (!(err instanceof Error && /404|503/u.test(err.message))) {
|
||||
setError(err instanceof Error ? err.message : '加载今日早报失败');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newsDraftForm?.sourceUserId.trim()) {
|
||||
setNewsDraftTodayStatus(null);
|
||||
setNewsDraftPreview(null);
|
||||
return;
|
||||
}
|
||||
void refreshTodayNewsMorningDraft(newsDraftForm.sourceUserId);
|
||||
}, [newsDraftForm?.sourceUserId, refreshTodayNewsMorningDraft]);
|
||||
|
||||
const runAction = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -314,6 +407,90 @@ export function WechatPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveNewsMorningDraft = async () => {
|
||||
if (!newsDraftForm) return;
|
||||
setNewsDraftSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await patchWechatNewsMorningDraftConfig({
|
||||
enabled: newsDraftForm.enabled,
|
||||
autoPushEnabled: newsDraftForm.autoPushEnabled,
|
||||
pushHour: Number(newsDraftForm.pushHour) || 6,
|
||||
pushMinute: Number(newsDraftForm.pushMinute) || 0,
|
||||
timezone: newsDraftForm.timezone.trim() || 'Asia/Shanghai',
|
||||
sourceUserId: newsDraftForm.sourceUserId.trim() || null,
|
||||
pageSlugPattern: newsDraftForm.pageSlugPattern.trim() || 'daily-news-*',
|
||||
author: newsDraftForm.author.trim() || 'TKMind',
|
||||
});
|
||||
const nextForm = newsMorningDraftToForm(result, newsDraftTemplate ?? undefined);
|
||||
setNewsDraftForm(nextForm);
|
||||
setSavedNewsDraftForm(nextForm);
|
||||
setNotice('新闻早报草稿推送配置已保存。');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setNewsDraftSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewNewsMorningDraft = async () => {
|
||||
setNewsDraftWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await getWechatNewsMorningDraftTodayStatus();
|
||||
setNewsDraftTodayStatus(status);
|
||||
if (status.todayPageReady) {
|
||||
setNewsDraftPreview(await previewWechatNewsMorningDraftToday());
|
||||
} else {
|
||||
setNewsDraftPreview(null);
|
||||
setError(status.message ?? '今日新闻早报尚未生成');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '预览失败');
|
||||
} finally {
|
||||
setNewsDraftWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushNewsMorningDraft = async () => {
|
||||
if (!newsDraftForm) return;
|
||||
if (!newsDraftForm.sourceUserId.trim()) {
|
||||
setError('请先填写来源用户 ID');
|
||||
return;
|
||||
}
|
||||
setNewsDraftWorking(true);
|
||||
setError(null);
|
||||
setNotice('正在推送到微信草稿箱,请稍候(约 5–15 秒)…');
|
||||
try {
|
||||
if (newsDraftDirty || !newsDraftForm.enabled) {
|
||||
const saved = await patchWechatNewsMorningDraftConfig({
|
||||
enabled: true,
|
||||
autoPushEnabled: newsDraftForm.autoPushEnabled,
|
||||
pushHour: Number(newsDraftForm.pushHour) || 6,
|
||||
pushMinute: Number(newsDraftForm.pushMinute) || 0,
|
||||
timezone: newsDraftForm.timezone.trim() || 'Asia/Shanghai',
|
||||
sourceUserId: newsDraftForm.sourceUserId.trim() || null,
|
||||
pageSlugPattern: newsDraftForm.pageSlugPattern.trim() || 'daily-news-*',
|
||||
author: newsDraftForm.author.trim() || 'TKMind',
|
||||
});
|
||||
const nextForm = newsMorningDraftToForm(saved, newsDraftTemplate ?? undefined);
|
||||
setNewsDraftForm(nextForm);
|
||||
setSavedNewsDraftForm(nextForm);
|
||||
}
|
||||
const result = await pushWechatNewsMorningDraft();
|
||||
setNotice(`草稿已写入微信后台,media_id:${result.draftMediaId ?? '—'}`);
|
||||
setNewsDraftPreview(result.preview ?? null);
|
||||
setNewsDraftRuns(await listWechatNewsMorningDraftRuns(10));
|
||||
await refreshTodayNewsMorningDraft(newsDraftForm.sourceUserId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '推送失败');
|
||||
setNotice(null);
|
||||
} finally {
|
||||
setNewsDraftWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveIntentRouter = async () => {
|
||||
if (!intentForm) return;
|
||||
setIntentSaving(true);
|
||||
@@ -425,6 +602,291 @@ export function WechatPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{newsDraftForm ? (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h2>新闻早报 · 微信草稿箱</h2>
|
||||
<p className="muted" style={{ marginTop: 6 }}>
|
||||
将 MindSpace 生成的新闻早报 HTML 转为服务号图文草稿。模板版本 {newsDraftForm.templateVersion}(0910 版式)。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void handleSaveNewsMorningDraft()}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking || !newsDraftDirty}
|
||||
>
|
||||
{newsDraftSaving ? '保存中…' : newsDraftDirty ? '保存配置' : '已保存'}
|
||||
</button>
|
||||
</div>
|
||||
{newsDraftTodayStatus ? (
|
||||
<div
|
||||
className="admin-card"
|
||||
style={{ marginTop: 16, background: 'var(--panel-2, rgba(255,255,255,.03))' }}
|
||||
>
|
||||
<div className="admin-card-head">
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 4 }}>今日早报 · {newsDraftTodayStatus.dateKey}</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
期望文件名:<code>{newsDraftTodayStatus.expectedSlug}.html</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void refreshTodayNewsMorningDraft(newsDraftForm.sourceUserId)}
|
||||
disabled={busy || newsDraftWorking || !newsDraftForm.sourceUserId.trim()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<dl className="admin-dl">
|
||||
<div>
|
||||
<dt>生成状态</dt>
|
||||
<dd className={newsDraftTodayStatus.todayPageReady ? 'wechat-ok' : 'text-error'}>
|
||||
{newsDraftTodayStatus.todayPageReady ? '今日页面已生成' : newsDraftTodayStatus.message ?? '待生成'}
|
||||
</dd>
|
||||
</div>
|
||||
{newsDraftTodayStatus.page ? (
|
||||
<>
|
||||
<div>
|
||||
<dt>页面</dt>
|
||||
<dd>
|
||||
<a href={newsDraftTodayStatus.page.publicUrl} target="_blank" rel="noreferrer">
|
||||
{newsDraftTodayStatus.page.slug}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新时间</dt>
|
||||
<dd>{dateLabel(newsDraftTodayStatus.page.modifiedAt)}</dd>
|
||||
</div>
|
||||
</>
|
||||
) : newsDraftTodayStatus.latestPageSlug ? (
|
||||
<div>
|
||||
<dt>最近页面</dt>
|
||||
<dd className="muted">{newsDraftTodayStatus.latestPageSlug}(非今日)</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{newsDraftTodayStatus.autoPushEnabled ? (
|
||||
<div>
|
||||
<dt>自动推送</dt>
|
||||
<dd>
|
||||
计划 {String(newsDraftTodayStatus.pushHour ?? 6).padStart(2, '0')}:
|
||||
{String(newsDraftTodayStatus.pushMinute ?? 0).padStart(2, '0')}
|
||||
{newsDraftTodayStatus.runs.push?.status === 'success'
|
||||
? ' · 今日已推送草稿'
|
||||
: newsDraftTodayStatus.runs.push?.status === 'failed'
|
||||
? ` · 推送失败:${newsDraftTodayStatus.runs.push.errorMessage ?? '—'}`
|
||||
: ' · 待推送'}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-form" style={{ marginTop: 16 }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newsDraftForm.enabled}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, enabled: event.target.checked })
|
||||
}
|
||||
/>{' '}
|
||||
启用新闻早报草稿推送
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newsDraftForm.autoPushEnabled}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking || !newsDraftForm.enabled}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, autoPushEnabled: event.target.checked })
|
||||
}
|
||||
/>{' '}
|
||||
定时自动推送(Portal 在推送时间前约 1 小时自动生成当日页面,到点写入草稿箱)
|
||||
</label>
|
||||
<label>
|
||||
推送时间(北京时间)
|
||||
<div className="wechat-toolbar" style={{ marginTop: 8 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={newsDraftForm.pushHour}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, pushHour: event.target.value })
|
||||
}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<span>:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={newsDraftForm.pushMinute}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, pushMinute: event.target.value })
|
||||
}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
来源用户 ID
|
||||
<input
|
||||
value={newsDraftForm.sourceUserId}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
placeholder="a70ff537-8908-486e-9b6c-042e07cc25db"
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, sourceUserId: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
页面文件名匹配
|
||||
<input
|
||||
value={newsDraftForm.pageSlugPattern}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
placeholder="daily-news-*"
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, pageSlugPattern: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
草稿作者名(最多 8 字)
|
||||
<input
|
||||
value={newsDraftForm.author}
|
||||
maxLength={8}
|
||||
disabled={busy || newsDraftSaving || newsDraftWorking}
|
||||
onChange={(event) =>
|
||||
setNewsDraftForm((current) => current && { ...current, author: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{newsDraftTemplate ? (
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary className="muted">0910 模板说明(taskSpec 参考)</summary>
|
||||
<pre className="admin-code-block" style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{newsDraftTemplate.spec}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
<div className="wechat-toolbar" style={{ marginTop: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void handlePreviewNewsMorningDraft()}
|
||||
disabled={busy || newsDraftWorking || !newsDraftForm.sourceUserId.trim()}
|
||||
>
|
||||
{newsDraftWorking ? '处理中…' : '预览今日草稿'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-btn"
|
||||
onClick={() => void handlePushNewsMorningDraft()}
|
||||
disabled={busy || newsDraftWorking || !newsDraftForm.sourceUserId.trim()}
|
||||
title={
|
||||
!newsDraftForm.sourceUserId.trim()
|
||||
? '请先填写来源用户 ID'
|
||||
: !newsDraftForm.enabled
|
||||
? '建议勾选「启用新闻早报草稿推送」后再推送'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{newsDraftWorking ? '推送中…' : '立即推送到草稿箱'}
|
||||
</button>
|
||||
</div>
|
||||
{!newsDraftForm.enabled ? (
|
||||
<p className="muted" style={{ marginTop: 8 }}>
|
||||
提示:未勾选「启用」也可尝试推送;若失败请先启用并保存配置。
|
||||
</p>
|
||||
) : null}
|
||||
{newsDraftDirty ? (
|
||||
<p className="muted" style={{ marginTop: 8 }}>
|
||||
配置有未保存改动,点击推送时会自动保存后再推送到微信。
|
||||
</p>
|
||||
) : null}
|
||||
{newsDraftPreview ? (
|
||||
<div className="admin-card" style={{ marginTop: 16, background: 'var(--panel-2, rgba(255,255,255,.03))' }}>
|
||||
<h3 style={{ marginBottom: 8 }}>草稿预览</h3>
|
||||
<dl className="admin-dl">
|
||||
<div>
|
||||
<dt>页面</dt>
|
||||
<dd>
|
||||
<a href={newsDraftPreview.page.publicUrl} target="_blank" rel="noreferrer">
|
||||
{newsDraftPreview.page.slug}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>标题</dt>
|
||||
<dd>{newsDraftPreview.article.title}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>摘要</dt>
|
||||
<dd>{newsDraftPreview.article.digest}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>正文模式</dt>
|
||||
<dd>
|
||||
{'contentMode' in newsDraftPreview.article && newsDraftPreview.article.contentMode === 'inline_html'
|
||||
? `内联 HTML(公众号排版,非长图${newsDraftPreview.article.trimmed ? ',已自动裁剪' : ''})`
|
||||
: 'HTML 正文'}
|
||||
</dd>
|
||||
</div>
|
||||
{'contentLength' in newsDraftPreview.article && newsDraftPreview.article.contentLength ? (
|
||||
<div>
|
||||
<dt>正文字符数</dt>
|
||||
<dd>{newsDraftPreview.article.contentLength.toLocaleString()} / 20,000</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{'cardCount' in newsDraftPreview.article ? (
|
||||
<div>
|
||||
<dt>卡片数</dt>
|
||||
<dd>{newsDraftPreview.article.cardCount}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
{newsDraftRuns.length > 0 ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h3>最近推送记录</h3>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>状态</th>
|
||||
<th>页面</th>
|
||||
<th>draft media_id</th>
|
||||
<th>错误</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{newsDraftRuns.map((run) => (
|
||||
<tr key={run.id}>
|
||||
<td>{dateLabel(run.createdAt)}</td>
|
||||
<td className={statusClass(run.status)}>{statusLabel(run.status)}</td>
|
||||
<td>{run.pageSlug ?? '—'}</td>
|
||||
<td>{run.draftMediaId ?? '—'}</td>
|
||||
<td className={run.errorMessage ? 'text-error' : 'muted'}>{run.errorMessage ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{intentForm ? (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
AssetGatewayConfig,
|
||||
ImageMakeAdminConfig,
|
||||
ImageMakeAdminConfigResponse,
|
||||
AdminDashboardSummary,
|
||||
AdminServiceRestartAction,
|
||||
AdminServiceRestartResult,
|
||||
@@ -55,6 +57,11 @@ import type {
|
||||
WechatDeliveryLog,
|
||||
WechatDigestSubscription,
|
||||
WechatScheduleLlmConfig,
|
||||
WechatNewsMorningDraftConfig,
|
||||
WechatNewsMorningDraftPreview,
|
||||
WechatNewsMorningDraftRun,
|
||||
WechatNewsMorningDraftTemplate,
|
||||
WechatNewsMorningDraftTodayStatus,
|
||||
WechatIntentRouterAdminConfig,
|
||||
WechatIntentRouterConfigState,
|
||||
WechatIntentRouterRuntimeState,
|
||||
@@ -65,6 +72,13 @@ import type {
|
||||
WechatWebNotification,
|
||||
MindSearchConfig,
|
||||
MindSearchServiceTestResult,
|
||||
NewsEngineCollectionDetail,
|
||||
NewsEngineCollectionSummary,
|
||||
NewsEngineConfig,
|
||||
NewsEngineHealth,
|
||||
NewsEngineRankResult,
|
||||
NewsEngineRunSummary,
|
||||
NewsEngineSchedulerStatus,
|
||||
AdminTemplateCatalogItem,
|
||||
AdminTemplateCatalogPatch,
|
||||
} from '../types';
|
||||
@@ -458,6 +472,58 @@ export async function updateWechatScheduleLlmConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWechatNewsMorningDraftConfig(): Promise<{
|
||||
config: WechatNewsMorningDraftConfig;
|
||||
template: WechatNewsMorningDraftTemplate;
|
||||
}> {
|
||||
return portalFetch('/admin-api/wechat/news-morning-draft/config');
|
||||
}
|
||||
|
||||
export async function patchWechatNewsMorningDraftConfig(
|
||||
patch: Partial<Omit<WechatNewsMorningDraftConfig, 'updatedAt' | 'updatedBy' | 'templateVersion'>>,
|
||||
): Promise<WechatNewsMorningDraftConfig> {
|
||||
const result = await portalFetch<{ config: WechatNewsMorningDraftConfig }>(
|
||||
'/admin-api/wechat/news-morning-draft/config',
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
},
|
||||
);
|
||||
return result.config;
|
||||
}
|
||||
|
||||
export async function previewWechatNewsMorningDraft(): Promise<WechatNewsMorningDraftPreview> {
|
||||
return portalFetch<WechatNewsMorningDraftPreview>('/admin-api/wechat/news-morning-draft/preview');
|
||||
}
|
||||
|
||||
export async function getWechatNewsMorningDraftTodayStatus(): Promise<WechatNewsMorningDraftTodayStatus> {
|
||||
return portalFetch<WechatNewsMorningDraftTodayStatus>('/admin-api/wechat/news-morning-draft/today');
|
||||
}
|
||||
|
||||
export async function previewWechatNewsMorningDraftToday(): Promise<WechatNewsMorningDraftPreview> {
|
||||
return portalFetch<WechatNewsMorningDraftPreview>('/admin-api/wechat/news-morning-draft/preview/today');
|
||||
}
|
||||
|
||||
export async function pushWechatNewsMorningDraft(options?: { dryRun?: boolean }): Promise<{
|
||||
ok?: boolean;
|
||||
dryRun?: boolean;
|
||||
draftMediaId?: string;
|
||||
preview?: WechatNewsMorningDraftPreview;
|
||||
run?: WechatNewsMorningDraftRun;
|
||||
}> {
|
||||
return portalFetch('/admin-api/wechat/news-morning-draft/push', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWechatNewsMorningDraftRuns(limit = 20): Promise<WechatNewsMorningDraftRun[]> {
|
||||
const result = await portalFetch<{ runs: WechatNewsMorningDraftRun[] }>(
|
||||
`/admin-api/wechat/news-morning-draft/runs?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
return result.runs ?? [];
|
||||
}
|
||||
|
||||
export async function getWechatIntentRouterConfig(): Promise<WechatIntentRouterAdminConfig> {
|
||||
const result = await portalFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config');
|
||||
return result.config;
|
||||
@@ -498,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> {
|
||||
@@ -609,6 +704,10 @@ export type SeoGeoCatalogResult = {
|
||||
geoViews: number;
|
||||
crawlerViews: number;
|
||||
};
|
||||
umami?: {
|
||||
available: boolean;
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getSeoGeoPublicationCatalog(
|
||||
@@ -993,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() {
|
||||
|
||||
+215
@@ -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 = {
|
||||
@@ -330,6 +362,82 @@ export type WechatScheduleLlmConfig = {
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftConfig = {
|
||||
enabled: boolean;
|
||||
autoPushEnabled: boolean;
|
||||
pushHour: number;
|
||||
pushMinute: number;
|
||||
timezone: string;
|
||||
sourceUserId: string | null;
|
||||
pageSlugPattern: string;
|
||||
author: string;
|
||||
templateVersion: string;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftTemplate = {
|
||||
version: string;
|
||||
spec: string;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftPreview = {
|
||||
config: WechatNewsMorningDraftConfig;
|
||||
page: {
|
||||
slug: string;
|
||||
relativePath: string;
|
||||
publicUrl: string;
|
||||
modifiedAt: number;
|
||||
hasThumb: boolean;
|
||||
isTodayPage?: boolean;
|
||||
dateKey?: string;
|
||||
expectedSlug?: string;
|
||||
};
|
||||
article: {
|
||||
title: string;
|
||||
digest: string;
|
||||
content: string;
|
||||
contentSourceUrl?: string;
|
||||
contentMode?: 'inline_html' | 'html' | string;
|
||||
contentLength?: number;
|
||||
cardCount?: number;
|
||||
statsCount?: number;
|
||||
trimmed?: boolean;
|
||||
trimLevel?: number;
|
||||
};
|
||||
template: WechatNewsMorningDraftTemplate;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftRun = {
|
||||
id: string;
|
||||
status: string;
|
||||
pageSlug: string | null;
|
||||
pageUrl: string | null;
|
||||
draftMediaId: string | null;
|
||||
errorMessage: string | null;
|
||||
triggeredBy: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type WechatNewsMorningDraftTodayStatus = {
|
||||
configured: boolean;
|
||||
dateKey: string;
|
||||
expectedSlug: string;
|
||||
todayPageReady: boolean;
|
||||
page: WechatNewsMorningDraftPreview['page'] | null;
|
||||
latestPageSlug?: string | null;
|
||||
autoGenerateEnabled?: boolean;
|
||||
autoPushEnabled?: boolean;
|
||||
pushHour?: number;
|
||||
pushMinute?: number;
|
||||
generateLeadMinutes?: number;
|
||||
message?: string | null;
|
||||
runs: {
|
||||
generate: WechatNewsMorningDraftRun | null;
|
||||
push: WechatNewsMorningDraftRun | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type WechatIntentRouterAdminConfig = {
|
||||
enabled: boolean;
|
||||
shadowMode: boolean;
|
||||
@@ -854,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