Compare commits

...

10 Commits

Author SHA1 Message Date
john 05681d758e feat: add MindSearch admin controls 2026-07-15 13:46:22 +08:00
john 7df3ae35f1 feat(admin): surface Memory V2 candidate auto-review in management UI
Clarify active/canary/shadow modes and show auto-review metrics so operators do not need to manually approve every user memory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:16:57 +08:00
john 731d93843d feat(admin): add Memory V2 personal controls and Skill Runtime page
Wire admin UI and API routes to restored Memind personal memory and skill
runtime modules, including candidate review and runtime status display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:00:20 +08:00
john 4cbacb8529 Merge branch 'codex/space-quota-release'
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:18:42 +08:00
john a019da24b0 Merge branch 'codex/asset-gateway-controls' 2026-07-12 12:18:01 +08:00
john 6bd0faac40 refactor(admin): unify model center and Memory V2 as asset plugin cards
Align Providers and Memory V2 pages with asset-gateway card layout; fix
Memory V2 root layout and balanced grid on Asset Gateway.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:17:47 +08:00
john 0358484942 feat: present asset plugins as control cards 2026-07-12 10:00:30 +08:00
john 90cbcbba7c feat: bridge asset gateway controls to admin api 2026-07-12 09:54:47 +08:00
john a8a130c08f feat: add asset gateway admin controls 2026-07-12 09:51:20 +08:00
john 4b4bc898bd Support editing user space quota in admin 2026-06-25 23:48:29 +08:00
14 changed files with 2133 additions and 346 deletions
+156
View File
@@ -91,12 +91,16 @@ export function createAdminApp(services) {
const {
userAuth,
llmProviderService,
assetGatewayConfigService,
pool,
ready,
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -325,6 +329,33 @@ export function createAdminApp(services) {
res.json({ config: result });
});
adminApi.get('/asset-gateway/config', requireAdmin, async (_req, res) => {
if (!assetGatewayConfigService?.getConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
res.json(await assetGatewayConfigService.getConfig());
});
adminApi.put('/asset-gateway/config', requireAdmin, async (req, res) => {
if (!assetGatewayConfigService?.updateGlobalConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
res.json(await assetGatewayConfigService.updateGlobalConfig(req.body ?? {}, {
updatedBy: req.currentUser.id,
}));
});
adminApi.put('/asset-gateway/plugins/:pluginId', requireAdmin, async (req, res) => {
if (!assetGatewayConfigService?.updatePluginConfig) {
return res.status(503).json({ message: '资产能力配置服务未启用' });
}
const result = await assetGatewayConfigService.updatePluginConfig(req.params.pluginId, req.body ?? {}, {
updatedBy: req.currentUser.id,
});
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService) return res.status(503).json({ message: 'Memory V2 配置未启用' });
res.json(await memoryV2ConfigService.getAdminConfig());
@@ -338,6 +369,131 @@ export function createAdminApp(services) {
res.json(result);
});
adminApi.get('/mindsearch/config', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.getAdminConfig());
});
adminApi.patch('/mindsearch/config', requireAdmin, async (req, res) => {
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
});
adminApi.put('/mindsearch/config', requireAdmin, async (req, res) => {
if (!mindSearchConfigService?.updateAdminConfig) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.updateAdminConfig(req.body ?? {}, { updatedBy: req.currentUser.id }));
});
adminApi.get('/mindsearch/runtime', requireAdmin, async (_req, res) => {
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置未启用' });
res.json(await mindSearchConfigService.getRuntimeState());
});
adminApi.get('/memory-v2/status', requireAdmin, async (_req, res) => {
const portalBaseUrl = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
try {
const response = await fetch(`${portalBaseUrl}/api/runtime/status`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(2500),
});
if (!response.ok) {
return res.status(502).json({
ok: false,
message: `Memory V2 运行时状态返回 HTTP ${response.status}`,
});
}
const payload = await response.json();
return res.json({
ok: true,
checkedAt: Date.now(),
memory: payload?.memory ?? null,
});
} catch (err) {
return res.status(503).json({
ok: false,
checkedAt: Date.now(),
message: err instanceof Error ? err.message : 'Memory V2 运行时不可用',
});
}
});
adminApi.get('/memory-v2/candidates', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.listCandidates) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
try {
const status = String(req.query.status ?? 'candidate');
const userId = String(req.query.userId ?? '').trim() || null;
const limit = Number(req.query.limit ?? 50);
const [items, counts] = await Promise.all([
personalMemoryCandidateStore.listCandidates({ status, userId, limit }),
personalMemoryCandidateStore.countByStatus(),
]);
return res.json({ items, counts });
} catch (err) {
const missingTable = err?.code === 'ER_NO_SUCH_TABLE';
return res.status(missingTable ? 503 : 400).json({
message: missingTable ? '候选记忆表尚未执行本地迁移' : (err instanceof Error ? err.message : '候选记忆读取失败'),
});
}
});
adminApi.post('/memory-v2/candidates/:id/accept', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.reviewCandidate) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'accepted', {
reviewedBy: req.currentUser.id,
});
if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' });
return res.json(result);
});
adminApi.post('/memory-v2/candidates/:id/reject', requireAdmin, async (req, res) => {
if (!personalMemoryCandidateStore?.reviewCandidate) {
return res.status(503).json({ message: '候选记忆存储未启用' });
}
const result = await personalMemoryCandidateStore.reviewCandidate(req.params.id, 'rejected', {
reviewedBy: req.currentUser.id,
});
if (!result.updated) return res.status(409).json({ message: '候选记忆已处理或不存在' });
return res.json(result);
});
adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.getAdminConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json(await skillRuntimeConfigService.getAdminConfig());
});
const updateSkillRuntimeConfig = async (req, res) => {
if (!skillRuntimeConfigService?.updateAdminConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
const result = await skillRuntimeConfigService.updateAdminConfig(req.body?.config ?? req.body ?? {}, {
updatedBy: req.currentUser.id,
});
res.json(result);
};
adminApi.put('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
adminApi.patch('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig);
adminApi.get('/skill-runtime/catalog', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.listCatalogSummary) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json({ catalog: await skillRuntimeConfigService.listCatalogSummary() });
});
adminApi.get('/skill-runtime/runtime', requireAdmin, async (_req, res) => {
if (!skillRuntimeConfigService?.getPublicRuntimeConfig) {
return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' });
}
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
});
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
if (!systemTestAccountService) {
return res.status(503).json({ message: '系统测试账号服务未启用' });
+18
View File
@@ -29,6 +29,11 @@ export async function bootstrapAdminServices() {
updateMindSpaceConfig,
} = await importMemind('mindspace-config.mjs');
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
const { createMindSearchConfigService } = await importMemind('mindsearch-config.mjs');
const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs');
const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs');
const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs');
const { ensureAssetGatewaySchema } = await importMemind('db.mjs');
const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs');
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
const { createOpsApi } = await importMemind('admin-routes.mjs');
@@ -86,9 +91,18 @@ export async function bootstrapAdminServices() {
apiTarget,
apiSecret,
});
await ensureAssetGatewaySchema(pool);
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
env: process.env,
});
const mindSearchConfigService = createMindSearchConfigService(pool, { env: process.env });
await mindSearchConfigService.ensureSchema();
const personalMemoryCandidateStore = createPersonalMemoryCandidateStore(pool);
const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, {
env: process.env,
h5Root,
});
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, {
env: process.env,
});
@@ -133,6 +147,7 @@ export async function bootstrapAdminServices() {
pool,
userAuth,
llmProviderService,
assetGatewayConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
@@ -140,6 +155,9 @@ export async function bootstrapAdminServices() {
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+8
View File
@@ -98,12 +98,16 @@ ready
pool,
userAuth,
llmProviderService,
assetGatewayConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -120,12 +124,16 @@ ready
pool,
userAuth,
llmProviderService,
assetGatewayConfigService,
plazaOps,
createOpsApi,
wechatAdmin,
loadMindSpaceConfig,
updateMindSpaceConfig,
memoryV2ConfigService,
mindSearchConfigService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+8
View File
@@ -8,13 +8,16 @@ import { DashboardPage } from './admin/pages/DashboardPage';
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 { ProvidersPage } from './admin/pages/ProvidersPage';
import { SkillsPage } from './admin/pages/SkillsPage';
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
import { UserDetailPage } from './admin/pages/UserDetailPage';
import { UsersPage } from './admin/pages/UsersPage';
import { WechatPage } from './admin/pages/WechatPage';
import { AssetGatewayPage } from './admin/pages/AssetGatewayPage';
import { BlockedWordsPage } from './admin/pages/BlockedWordsPage';
import { SkillRuntimePage } from './admin/pages/SkillRuntimePage';
import { defaultHomePath } from './lib/routes';
import { OpsLayout } from './ops/components/OpsLayout';
import { AnalyticsPage } from './ops/pages/AnalyticsPage';
@@ -120,8 +123,11 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
<Route path="policies" element={<PoliciesPage />} />
<Route path="mindspace" element={<MindSpacePage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="mindsearch" element={<MindSearchPage />} />
<Route path="skill-runtime" element={<SkillRuntimePage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="asset-gateway" element={<AssetGatewayPage />} />
<Route path="blocked-words" element={<BlockedWordsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
@@ -166,8 +172,10 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|| pathname.startsWith('/policies')
|| pathname.startsWith('/mindspace')
|| pathname.startsWith('/memory-v2')
|| pathname.startsWith('/skill-runtime')
|| pathname.startsWith('/providers')
|| pathname.startsWith('/wechat')
|| pathname.startsWith('/asset-gateway')
) {
return role === 'admin' || role === undefined ? pathname : '/ops';
}
+3
View File
@@ -29,11 +29,14 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/wechat', label: '服务号' },
{ to: '/mindspace', label: 'MindSpace 配置' },
{ to: '/memory-v2', label: 'Memory V2' },
{ to: '/mindsearch', label: 'MindSearch' },
{ to: '/skill-runtime', label: 'Skill Runtime' },
{ to: '/system-tests', label: '系统测试验证' },
{ to: '/capabilities', label: '能力' },
{ to: '/skills', label: '技能' },
{ to: '/policies', label: '策略' },
{ to: '/providers', label: '统一模型中心' },
{ to: '/asset-gateway', label: '资产能力' },
{ to: '/blocked-words', label: '违禁词管理' },
],
},
+150
View File
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
getAssetGatewayConfig,
listLlmProviderKeys,
updateAssetGatewayConfig,
updateAssetPluginConfig,
} from '../../api/client';
import type { AssetGatewayConfig, AssetPluginConfig, LlmProviderKeyRow } from '../../types';
type PluginDraft = {
enabled: boolean;
provider: string;
llmProviderKeyId: string;
llmModel: string;
};
function draftFromPlugin(plugin: AssetPluginConfig): PluginDraft {
return {
enabled: plugin.enabled,
provider: plugin.provider ?? '',
llmProviderKeyId: plugin.llmProviderKeyId ?? '',
llmModel: plugin.llmModel ?? '',
};
}
const pluginVisuals: Record<string, { icon: string; accent: string }> = {
'asset-search': { icon: '⌕', accent: 'blue' },
'asset-generate': { icon: '✦', accent: 'violet' },
'asset-transform': { icon: '✧', accent: 'amber' },
'asset-analyze': { icon: '◉', accent: 'green' },
};
export function AssetGatewayPage() {
const [config, setConfig] = useState<AssetGatewayConfig | null>(null);
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
const [drafts, setDrafts] = useState<Record<string, PluginDraft>>({});
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const activeKeys = useMemo(() => keys.filter((key) => key.status === 'active'), [keys]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextConfig, nextKeys] = await Promise.all([getAssetGatewayConfig(), listLlmProviderKeys()]);
setConfig(nextConfig);
setKeys(nextKeys);
setDrafts(Object.fromEntries(nextConfig.plugins.map((plugin) => [plugin.pluginId, draftFromPlugin(plugin)])));
} catch (err) {
setError(err instanceof Error ? err.message : '加载资产能力配置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const updateDraft = (pluginId: string, patch: Partial<PluginDraft>) => {
setDrafts((current) => ({ ...current, [pluginId]: { ...current[pluginId], ...patch } }));
};
const saveGlobal = async (enabled: boolean) => {
setBusy('global'); setMessage(null); setError(null);
try {
const next = await updateAssetGatewayConfig({ enabled });
setConfig(next);
setMessage(`资产能力总开关已${enabled ? '开启' : '关闭'}。`);
} catch (err) { setError(err instanceof Error ? err.message : '保存总开关失败'); }
finally { setBusy(null); }
};
const savePlugin = async (plugin: AssetPluginConfig) => {
const draft = drafts[plugin.pluginId];
if (!draft) return;
setBusy(plugin.pluginId); setMessage(null); setError(null);
try {
const result = await updateAssetPluginConfig(plugin.pluginId, {
enabled: draft.enabled,
provider: draft.provider || null,
llmProviderKeyId: draft.llmProviderKeyId || null,
llmModel: draft.llmModel || null,
});
setConfig(result.config);
setDrafts(Object.fromEntries(result.config.plugins.map((item) => [item.pluginId, draftFromPlugin(item)])));
setMessage(`${plugin.label}配置已保存。`);
} catch (err) { setError(err instanceof Error ? err.message : '保存插件配置失败'); }
finally { setBusy(null); }
};
if (loading) return <div className="admin-page"><p className="muted">加载资产能力配置…</p></div>;
if (!config) return <div className="admin-page"><p className="banner banner-error">{error ?? '无法加载配置'}</p></div>;
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>资产能力</h2>
<p className="muted">可插拔图片、素材与处理能力。默认关闭,不会影响现有聊天或页面生成流程。</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-success">{message}</p>}
<section className="asset-gateway-hero">
<div>
<div className="asset-kicker">OPTIONAL CAPABILITY</div>
<h3>资产能力 Gateway</h3>
<p>关闭时所有调用自动降级为现有流程;配置保留,现有聊天和页面生成不受影响。</p>
</div>
<label className="asset-toggle">
<input type="checkbox" checked={config.enabled} disabled={busy === 'global'} onChange={(event) => void saveGlobal(event.target.checked)} />
<span>{config.enabled ? '已启用' : '已关闭'}</span>
</label>
</section>
<div className="asset-plugin-grid asset-plugin-grid--balanced">
{config.plugins.map((plugin) => {
const draft = drafts[plugin.pluginId] ?? draftFromPlugin(plugin);
const selectedKey = activeKeys.find((key) => key.id === draft.llmProviderKeyId);
const visual = pluginVisuals[plugin.pluginId] ?? { icon: '✦', accent: 'blue' };
return <section className={`asset-plugin-card asset-plugin-card--${visual.accent}`} key={plugin.pluginId}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">{visual.icon}</div>
<div><h3>{plugin.label}</h3><p>{plugin.description}</p></div>
<label className="asset-mini-toggle" title={`启用${plugin.label}`}>
<input type="checkbox" checked={draft.enabled} onChange={(event) => updateDraft(plugin.pluginId, { enabled: event.target.checked })} />
<span>{draft.enabled ? '开启' : '关闭'}</span>
</label>
</div>
<div className="asset-plugin-fields">
<label>运行 Provider<select value={draft.provider} onChange={(event) => updateDraft(plugin.pluginId, { provider: event.target.value })} disabled={!draft.enabled}>
<option value="">选择 Provider</option>
{plugin.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
</select></label>
{plugin.supportsLlm ? <>
<label>专属 LLM<select value={draft.llmProviderKeyId} onChange={(event) => updateDraft(plugin.pluginId, { llmProviderKeyId: event.target.value, llmModel: '' })} disabled={!draft.enabled}>
<option value="">不使用 LLM</option>
{activeKeys.map((key) => <option key={key.id} value={key.id}>{key.name}</option>)}
</select></label>
<label>模型<select value={draft.llmModel} onChange={(event) => updateDraft(plugin.pluginId, { llmModel: event.target.value })} disabled={!draft.enabled || !selectedKey}>
<option value="">选择模型</option>
{selectedKey?.models.map((model) => <option key={model} value={model}>{model}</option>)}
</select></label>
</> : <div className="asset-no-llm">确定性处理 · 不调用 LLM</div>}
</div>
<div className="asset-plugin-footer"><span className={`asset-status ${draft.enabled ? 'is-on' : ''}`}>{draft.enabled ? '待保存为启用' : '默认安全关闭'}</span><button type="button" className="send-btn" disabled={busy === plugin.pluginId} onClick={() => void savePlugin(plugin)}>{busy === plugin.pluginId ? '保存中…' : '保存配置'}</button></div>
</section>;
})}
</div>
</div>
);
}
+2
View File
@@ -9,8 +9,10 @@ const QUICK_LINKS = [
{ to: '/users', label: '用户管理', desc: '创建账号、启用禁用' },
{ to: '/billing/recharge', label: '充值', desc: '为用户账户充值' },
{ to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' },
{ to: '/asset-gateway', label: '资产能力', desc: '可插拔素材插件、Provider 与专属 LLM 选择' },
{ to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' },
{ to: '/memory-v2', label: 'Memory V2', desc: '长期记忆 backend 与前置路由开关' },
{ to: '/skill-runtime', label: 'Skill Runtime', desc: 'H5 manifest 关键词路由开关' },
{ to: '/system-tests', label: '系统测试验证', desc: '选择测试账号执行联调并汇总问题反馈' },
{ to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' },
] as const;
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { getMindSearchConfig, updateMindSearchConfig } from '../../api/client';
import type { MindSearchConfig } from '../../types';
const initial: MindSearchConfig = {
enabled: false,
mode: 'off',
providers: { searxng: false, github: false, reader: false },
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
};
export function MindSearchPage() {
const [config, setConfig] = useState(initial);
const [meta, setMeta] = useState<{ source?: string; updatedAt?: number | null; updatedBy?: string | null }>({});
const [message, setMessage] = useState('加载配置中…');
useEffect(() => {
getMindSearchConfig()
.then((result) => {
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
setMeta(result);
setMessage('');
})
.catch((error) => setMessage(error instanceof Error ? error.message : '配置加载失败'));
}, []);
const save = async () => {
setMessage('保存中…');
try {
const result = await updateMindSearchConfig(config);
setConfig({ ...initial, ...result.config, settings: { ...initial.settings, ...result.config.settings } });
setMeta(result);
setMessage('已保存到数据库;新会话生效,旧会话不受影响');
} catch (error) {
setMessage(error instanceof Error ? error.message : '保存失败');
}
};
const toggleProvider = (key: keyof MindSearchConfig['providers']) =>
setConfig((current) => ({ ...current, providers: { ...current.providers, [key]: !current.providers[key] } }));
const updateSetting = (key: keyof MindSearchConfig['settings'], value: string | number) =>
setConfig((current) => ({ ...current, settings: { ...current.settings, [key]: value } }));
return (
<section className="admin-page">
<div className="admin-page-header"><div><h1>MindSearch</h1><p>可插拔外部搜索增强。关闭时完全保持原有搜索、记忆和上下文链路。</p></div></div>
<div className="admin-card">
<h2>总开关与模式</h2>
<label><input type="checkbox" checked={config.enabled} onChange={(event) => setConfig({ ...config, enabled: event.target.checked, mode: event.target.checked ? (config.mode === 'off' ? 'shadow' : config.mode) : 'off' })} /> 启用 MindSearch</label>
<label>运行模式 <select value={config.mode} onChange={(event) => setConfig({ ...config, mode: event.target.value as MindSearchConfig['mode'] })}><option value="off">关闭</option><option value="shadow">Shadow(只记录,不注入)</option><option value="assist">Assist(补充工具)</option></select></label>
<h2>Provider</h2>
<fieldset><legend>可用 Provider</legend>
<label><input type="checkbox" checked={config.providers.searxng} onChange={() => toggleProvider('searxng')} /> SearXNG Web/News</label>
<label><input type="checkbox" checked={config.providers.github} onChange={() => toggleProvider('github')} /> GitHub Code(Token 通过环境变量配置,不在页面回显)</label>
<label><input type="checkbox" checked={config.providers.reader} onChange={() => toggleProvider('reader')} /> Reader(阻止 localhost/内网地址)</label>
</fieldset>
<h2>运行参数</h2>
<label>SearXNG 地址 <input value={config.settings.searxngEndpoint} placeholder="http://127.0.0.1:8080/search" onChange={(event) => updateSetting('searxngEndpoint', event.target.value)} /></label>
<label>最大结果数 <input type="number" min={1} max={20} value={config.settings.maxResults} onChange={(event) => updateSetting('maxResults', Number(event.target.value))} /></label>
<label>Provider 超时(毫秒) <input type="number" min={1000} max={30000} value={config.settings.timeoutMs} onChange={(event) => updateSetting('timeoutMs', Number(event.target.value))} /></label>
<label>Reader 最大字符数 <input type="number" min={1000} max={50000} value={config.settings.readerMaxChars} onChange={(event) => updateSetting('readerMaxChars', Number(event.target.value))} /></label>
<button type="button" onClick={save}>保存配置</button>
{message && <p role="status">{message}</p>}
<p>配置来源:{meta.source === 'admin' ? '数据库' : '环境变量默认值'};最后更新:{meta.updatedAt ? new Date(meta.updatedAt).toLocaleString() : '尚未保存'}{meta.updatedBy ? `;操作人:${meta.updatedBy}` : ''}</p>
</div>
</section>
);
}
+215 -160
View File
@@ -35,6 +35,14 @@ import type {
const CUSTOM_PROVIDER_ID = '__custom__';
const EXECUTOR_VISUALS: Record<string, { icon: string; accent: string }> = {
goose: { icon: 'G', accent: 'blue' },
aider: { icon: 'A', accent: 'amber' },
openhands: { icon: 'O', accent: 'violet' },
};
const PROVIDER_ACCENTS = ['blue', 'violet', 'green', 'amber'] as const;
const defaultProviderForm = {
providerId: CUSTOM_PROVIDER_ID,
name: '',
@@ -270,40 +278,41 @@ function ExecutorCard({
}
}, [model, selectedKey]);
const visual = EXECUTOR_VISUALS[binding.executor] ?? { icon: '✦', accent: 'blue' };
return (
<article className="executor-card">
<div className="admin-card-head">
<section className={`asset-plugin-card asset-plugin-card--${visual.accent} model-center-card`}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">{visual.icon}</div>
<div>
<h3>{binding.executorLabel}</h3>
<p className="muted">{binding.executorDescription}</p>
<p>{binding.executorDescription}</p>
</div>
<label className="inline-check">
<label className="asset-mini-toggle" aria-label={`${binding.executorLabel} 启用状态`}>
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
启用
<span>{enabled ? '启用' : '关闭'}</span>
</label>
</div>
<div className="executor-form-grid">
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value="">选择模型</option>
{modelOptions.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<button
type="button"
className="send-btn"
disabled={busy || (enabled && (!keyId || !model))}
onClick={() => void onSave(binding, keyId || null, model, enabled)}
>
保存绑定
</button>
<div className="asset-plugin-fields">
<label>
Provider
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
</label>
<label>
模型
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value="">选择模型</option>
{modelOptions.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
</div>
<div className="executor-footer">
<div className="model-center-card-body">
<div className="executor-meta">
<span>{statusText(launchStatus)}</span>
{runtime?.ok ? <span>{runtime.providerName} · {runtime.model}</span> : <span>{runtime?.message ?? '运行配置未就绪'}</span>}
@@ -348,10 +357,24 @@ function ExecutorCard({
</div>
</>
)}
{launchStatus?.logFile ? <span className="mono executor-log">{launchStatus.logFile}</span> : null}
</div>
{launchStatus?.logFile ? <span className="mono executor-log">{launchStatus.logFile}</span> : null}
</article>
<div className="asset-plugin-footer">
<span className={`asset-status ${enabled ? 'is-on' : ''}`}>
{enabled ? '绑定启用中' : '绑定已关闭'}
</span>
<button
type="button"
className="send-btn"
disabled={busy || (enabled && (!keyId || !model))}
onClick={() => void onSave(binding, keyId || null, model, enabled)}
>
保存绑定
</button>
</div>
</section>
);
}
@@ -387,45 +410,61 @@ function VisionSection({
}, [model, selectedKey]);
return (
<section className="admin-card providers-panel">
<div className="admin-card-head providers-panel-head">
<div>
<h3>图片任务模型</h3>
<p className="muted">发送含图片的消息时自动切换到此模型(需支持视觉能力,如 Qwen VL)</p>
<div className="asset-plugin-grid model-center-vision-grid">
<section className="asset-plugin-card asset-plugin-card--green model-center-card">
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">◉</div>
<div>
<h3>图片任务模型</h3>
<p>发送含图片的消息时自动切换到此模型(需支持视觉能力,如 Qwen VL)</p>
</div>
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
{settings?.keyId ? `${settings.keyName} · ${settings.visionModel}` : '未配置'}
</span>
</div>
{settings?.keyId ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void onClear()}>
清除
</button>
<div className="asset-plugin-fields">
<label>
Provider
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
</label>
<label>
视觉模型
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value="">选择视觉模型</option>
{modelOptions.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</label>
</div>
{!settings?.keyId ? (
<p className="model-center-card-note">未配置时,图片消息将由默认模型处理。</p>
) : null}
</div>
{settings?.keyId ? (
<p className="muted" style={{ marginBottom: '0.75rem' }}>
当前:<strong>{settings.keyName}</strong> · <span className="mono">{settings.visionModel}</span>
</p>
) : (
<p className="muted" style={{ marginBottom: '0.75rem' }}>未配置,图片消息将由默认模型处理</p>
)}
<div className="executor-form-grid">
<ProviderSelect keys={keys} value={keyId} onChange={setKeyId} />
<select value={model} onChange={(event) => setModel(event.target.value)} disabled={!keyId}>
<option value="">选择视觉模型</option>
{modelOptions.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
<button
type="button"
className="send-btn"
disabled={busy || !keyId || !model}
onClick={() => void onSave(keyId, model)}
>
保存
</button>
</div>
</section>
<div className="asset-plugin-footer">
<span className={`asset-status ${settings?.keyId ? 'is-on' : ''}`}>
{settings?.keyId ? '已绑定视觉模型' : '等待配置'}
</span>
<div className="model-center-card-actions">
{settings?.keyId ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void onClear()}>
清除
</button>
) : null}
<button
type="button"
className="send-btn"
disabled={busy || !keyId || !model}
onClick={() => void onSave(keyId, model)}
>
保存
</button>
</div>
</div>
</section>
</div>
);
}
@@ -723,123 +762,139 @@ export function ProvidersPage() {
}
};
const activeProviderCount = keys.filter((item) => item.status === 'active').length;
return (
<div className="admin-page providers-page">
<div className="admin-page-head providers-hero">
<div>
<h2>统一模型中心</h2>
<p className="muted">管理 Provider、执行器绑定和默认模型</p>
</div>
<div className="providers-hero-meta">
<div className="providers-hero-stat">
<span className="providers-hero-label">当前默认</span>
<strong>{globalSettings?.globalModel ?? '未设置'}</strong>
</div>
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
添加 Provider
</button>
</div>
<div className="admin-page">
<div className="admin-page-head">
<h2>统一模型中心</h2>
<p className="muted">管理 Provider、执行器绑定和默认模型</p>
</div>
{message && <p className="banner banner-info">{message}</p>}
{error && <p className="banner banner-error">{error}</p>}
<section className="admin-card providers-panel providers-panel-primary">
<div className="admin-card-head providers-panel-head">
<div>
<h3>执行器绑定</h3>
<p className="muted">把 Provider 和模型绑定到 Goose、Aider、OpenHands</p>
</div>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSync()}>
同步 Goose 绑定
</button>
<section className="asset-gateway-hero">
<div>
<div className="asset-kicker">MODEL CENTER</div>
<h3>默认模型与 Provider 资源池</h3>
<p>
当前默认模型 <strong className="mono">{globalSettings?.globalModel ?? '未设置'}</strong>
{' · '}
Provider 资源池 {activeProviderCount}/{keys.length} 可用
</p>
</div>
{loading ? (
<p className="muted">加载中...</p>
) : (
<div className="executor-grid">
{bindings.map((binding) => (
<ExecutorCard
key={binding.executor}
binding={binding}
keys={activeKeys}
runtime={runtimes.find((item) => item.executor === binding.executor)}
plan={plans.find((item) => item.executor === binding.executor)}
launchStatus={launchStatuses[binding.executor]}
busy={busy}
onSave={handleSaveBinding}
onLaunch={handleLaunch}
onStop={handleStop}
onRestart={handleRestart}
/>
))}
</div>
)}
</section>
<section className="admin-card providers-panel providers-panel-secondary">
<div className="admin-card-head providers-panel-head">
<div>
<h3>Provider 资源池</h3>
<p className="muted">按名称纵向查看,更接近 list</p>
</div>
<div className="model-center-hero-actions">
<button type="button" className="ghost-btn" disabled={busy || loading} onClick={() => void load()}>
刷新
</button>
<button type="button" className="send-btn" disabled={busy || loading} onClick={openCreateProvider}>
添加 Provider
</button>
</div>
</section>
<div className="providers-list">
{keys.map((row) => (
<article key={row.id} className="provider-list-item">
<div className="provider-list-main">
<div className="provider-list-top">
<div className="providers-name">
<span>{row.name}</span>
{row.isSelected ? <span className="risk-pill risk-low">默认</span> : null}
</div>
<span className={`provider-state ${row.status === 'active' ? 'is-active' : 'is-disabled'}`}>
{row.status === 'active' ? '可用' : '禁用'}
</span>
<div className="model-center-section-head">
<h3 className="model-center-section-title">执行器绑定</h3>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSync()}>
同步 Goose 绑定
</button>
</div>
{loading ? (
<p className="muted">加载中...</p>
) : (
<div className="asset-plugin-grid">
{bindings.map((binding) => (
<ExecutorCard
key={binding.executor}
binding={binding}
keys={activeKeys}
runtime={runtimes.find((item) => item.executor === binding.executor)}
plan={plans.find((item) => item.executor === binding.executor)}
launchStatus={launchStatuses[binding.executor]}
busy={busy}
onSave={handleSaveBinding}
onLaunch={handleLaunch}
onStop={handleStop}
onRestart={handleRestart}
/>
))}
</div>
)}
<h3 className="model-center-section-title">Provider 资源池</h3>
<div className="asset-plugin-grid">
{keys.map((row, index) => {
const accent = PROVIDER_ACCENTS[index % PROVIDER_ACCENTS.length];
return (
<section key={row.id} className={`asset-plugin-card asset-plugin-card--${accent} model-center-card model-center-provider-card`}>
<div className="asset-plugin-card-head">
<div className="asset-plugin-icon" aria-hidden="true">✦</div>
<div>
<h3>{row.name}</h3>
<p>{row.providerKind === 'custom' ? row.apiUrl : row.providerLabel}</p>
</div>
<div className="provider-list-meta">
<span>{row.providerKind === 'custom' ? row.apiUrl : row.providerLabel}</span>
<span className={`asset-status ${row.status === 'active' ? 'is-on' : ''}`}>
{row.status === 'active' ? '可用' : '禁用'}
</span>
</div>
<div className="model-center-provider-badges">
{row.isSelected ? <span className="risk-pill risk-low">默认</span> : null}
{row.isVisionSelected ? <span className="risk-pill risk-medium">图片任务</span> : null}
</div>
<div className="asset-plugin-fields model-center-provider-meta">
<div className="model-center-meta-row">
<span>默认模型</span>
<span className="mono">{row.defaultModel}</span>
</div>
<div className="model-center-meta-row">
<span>API Key</span>
<span className="mono">{row.apiKeyMasked}</span>
</div>
</div>
<div className="admin-actions providers-actions provider-list-actions">
<button
type="button"
className="ghost-btn"
disabled={busy}
onClick={() => setProviderDialog({ mode: 'edit', row, form: providerFormFromRow(row) })}
>
编辑
</button>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleTestProvider(row)}>
测试
</button>
{!row.isSelected && row.status === 'active' ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSelectProvider(row.id)}>
设为默认
<div className="asset-plugin-footer">
<span className={`asset-status ${row.status === 'active' ? 'is-on' : ''}`}>
{row.isSelected ? '当前默认 Provider' : '备用配置'}
</span>
<div className="model-center-card-actions">
<button
type="button"
className="ghost-btn"
disabled={busy}
onClick={() => setProviderDialog({ mode: 'edit', row, form: providerFormFromRow(row) })}
>
编辑
</button>
) : null}
<button
type="button"
className="ghost-btn"
disabled={busy || row.isSelected}
onClick={() => void handleToggleProviderStatus(row)}
>
{row.status === 'active' ? '禁用' : '恢复'}
</button>
<button type="button" className="danger-btn" disabled={busy || row.isSelected} onClick={() => void handleDeleteProvider(row)}>
删除
</button>
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleTestProvider(row)}>
测试
</button>
{!row.isSelected && row.status === 'active' ? (
<button type="button" className="ghost-btn" disabled={busy} onClick={() => void handleSelectProvider(row.id)}>
设为默认
</button>
) : null}
<button
type="button"
className="ghost-btn"
disabled={busy || row.isSelected}
onClick={() => void handleToggleProviderStatus(row)}
>
{row.status === 'active' ? '禁用' : '恢复'}
</button>
<button type="button" className="danger-btn" disabled={busy || row.isSelected} onClick={() => void handleDeleteProvider(row)}>
删除
</button>
</div>
</div>
</article>
))}
</div>
</section>
</section>
);
})}
</div>
<h3 className="model-center-section-title">图片任务模型</h3>
<VisionSection
keys={activeKeys}
settings={visionSettings}
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useState } from 'react';
import {
getSkillRuntimeAdminConfig,
listSkillRuntimeCatalog,
updateSkillRuntimeAdminConfig,
} from '../../api/client';
import type { SkillRuntimeAdminConfig, SkillRuntimeCatalogItem } from '../../types';
export function SkillRuntimePage() {
const [config, setConfig] = useState<SkillRuntimeAdminConfig>({
router: { v2Enabled: false, manifestRoutingEnabled: false },
});
const [catalog, setCatalog] = useState<SkillRuntimeCatalogItem[]>([]);
const [source, setSource] = useState('default');
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [configResult, catalogRows] = await Promise.all([
getSkillRuntimeAdminConfig(),
listSkillRuntimeCatalog(),
]);
setConfig(configResult.config);
setSource(configResult.source ?? 'default');
setUpdatedAt(configResult.updatedAt ?? null);
setCatalog(catalogRows);
} catch (err) {
setError(err instanceof Error ? err.message : '加载 Skill Runtime 配置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const handleSave = async () => {
setSaving(true);
setError(null);
setNotice(null);
try {
const result = await updateSkillRuntimeAdminConfig(config);
setConfig(result.config);
setSource(result.source ?? 'admin-db');
setUpdatedAt(result.updatedAt ?? null);
setNotice('Skill Runtime 配置已保存。H5 用户刷新或重新登录后会读取新开关。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
const manifestSkillCount = catalog.filter((item) => item.hasManifest && item.triggerKeywords.length > 0).length;
return (
<div className="admin-page">
<div className="admin-page-head">
<h2>Skill Runtime</h2>
<p className="muted">控制 H5 Skill Router v2 与 manifest 关键词路由(默认关闭)</p>
</div>
{loading ? <p className="muted">加载中…</p> : null}
{error ? <p className="banner banner-error">{error}</p> : null}
{notice ? <p className="banner banner-info">{notice}</p> : null}
{!loading ? (
<>
<section className="admin-card">
<h3>Router 开关</h3>
<p className="muted">
当前来源:<strong>{source}</strong>
{updatedAt
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
: ''}
</p>
<p className="muted">
开启后仅对已授予 skill 的用户生效;manifest 匹配失败会回退 legacy 路由。
</p>
<label className="inline-check">
<input
type="checkbox"
checked={config.router.v2Enabled}
onChange={(event) => {
const v2Enabled = event.target.checked;
setConfig({
router: {
v2Enabled,
manifestRoutingEnabled: v2Enabled ? config.router.manifestRoutingEnabled : false,
},
});
}}
/>
<span>
<strong>启用 Skill Router v2</strong>
<span className="muted">总开关。关闭时 H5 走 legacy regex 路由。</span>
</span>
</label>
<label className="inline-check">
<input
type="checkbox"
checked={config.router.manifestRoutingEnabled}
disabled={!config.router.v2Enabled}
onChange={(event) => {
setConfig({
router: {
...config.router,
manifestRoutingEnabled: event.target.checked,
},
});
}}
/>
<span>
<strong>启用 manifest 关键词路由</strong>
<span className="muted">
读取 skills/*/skill.yaml 的 trigger.keywords。当前已配置 manifest 的 skill:{manifestSkillCount}{' '}
个。
</span>
</span>
</label>
<div className="admin-actions">
<button type="button" className="send-btn" disabled={saving} onClick={() => void handleSave()}>
{saving ? '保存中…' : '保存配置'}
</button>
<button
type="button"
className="admin-btn-secondary"
disabled={loading || saving}
onClick={() => void load()}
>
重新加载
</button>
</div>
</section>
<section className="admin-card">
<h3>技能 Catalog 预览</h3>
<p className="muted">
「Manifest」表示该 skill 目录下是否存在 <code>skill.yaml</code>(Router v2 关键词路由配置)。
目前 Phase 1 仅 <strong>product-campaign-page</strong> 做了 manifest 试点;其余 skill 仍只有 legacy{' '}
<code>SKILL.md</code>,因此 Manifest 显示「否」是正常现象。
</p>
<p className="muted">有 skill.yaml 且配置了关键词的条目会在 Router v2 开启后参与匹配。</p>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Skill</th>
<th>Manifest</th>
<th>关键词</th>
<th>Prompt</th>
<th>优先级</th>
</tr>
</thead>
<tbody>
{catalog.map((item) => (
<tr key={item.name}>
<td>
<strong>{item.name}</strong>
<div className="muted">{item.description}</div>
</td>
<td>{item.hasManifest ? '是' : '否'}</td>
<td>{item.triggerKeywords.length ? item.triggerKeywords.join('、') : '—'}</td>
<td>{item.routerPromptKey ?? '—'}</td>
<td>{item.routerPriority || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
) : null}
</div>
);
}
+80
View File
@@ -1,4 +1,5 @@
import type {
AssetGatewayConfig,
AdminDashboardSummary,
AdminServiceRestartAction,
AdminServiceRestartResult,
@@ -24,6 +25,7 @@ import type {
LlmProviderKeyRow,
PlanDefinition,
PlanSyncResult,
PersonalMemoryCandidateListResponse,
PolicyDefinition,
PolicyMap,
PortalUser,
@@ -31,6 +33,10 @@ import type {
MemoryV2AdminConfig,
MemoryV2AdminConfigResponse,
MemoryV2ModelApiType,
MemoryV2RuntimeStatusResponse,
SkillRuntimeAdminConfig,
SkillRuntimeAdminConfigResponse,
SkillRuntimeCatalogItem,
SkillDefinition,
SkillMap,
UsageRecord,
@@ -41,6 +47,7 @@ import type {
WechatScheduleLlmConfig,
WechatMessage,
WechatWebNotification,
MindSearchConfig,
} from '../types';
export class ApiError extends Error {
@@ -279,6 +286,34 @@ export async function updateWechatScheduleLlmConfig(
});
}
export async function getAssetGatewayConfig(): Promise<AssetGatewayConfig> {
return portalFetch('/admin-api/asset-gateway/config');
}
export async function updateAssetGatewayConfig(
payload: Pick<AssetGatewayConfig, 'enabled'>,
): Promise<AssetGatewayConfig> {
return portalFetch('/admin-api/asset-gateway/config', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function updateAssetPluginConfig(
pluginId: string,
payload: {
enabled: boolean;
provider?: string | null;
llmProviderKeyId?: string | null;
llmModel?: string | null;
},
): Promise<{ ok: boolean; config: AssetGatewayConfig }> {
return portalFetch(`/admin-api/asset-gateway/plugins/${encodeURIComponent(pluginId)}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function getMindSpaceAdminConfig(): Promise<MindSpaceAdminConfig> {
const result = await portalFetch<{ config: MindSpaceAdminConfig }>('/admin-api/mindspace/config');
return result.config;
@@ -298,6 +333,25 @@ export async function getMemoryV2AdminConfig(): Promise<MemoryV2AdminConfigRespo
return portalFetch('/admin-api/memory-v2/config');
}
export async function getMemoryV2RuntimeStatus(): Promise<MemoryV2RuntimeStatusResponse> {
return portalFetch('/admin-api/memory-v2/status');
}
export async function listPersonalMemoryCandidates(
status = 'candidate',
): Promise<PersonalMemoryCandidateListResponse> {
return portalFetch(`/admin-api/memory-v2/candidates?status=${encodeURIComponent(status)}&limit=50`);
}
export async function reviewPersonalMemoryCandidate(
id: string,
action: 'accept' | 'reject',
): Promise<{ updated: boolean; status: string; reviewedAt: number }> {
return portalFetch(`/admin-api/memory-v2/candidates/${encodeURIComponent(id)}/${action}`, {
method: 'POST',
});
}
export async function updateMemoryV2AdminConfig(
payload: Partial<MemoryV2AdminConfig>,
): Promise<MemoryV2AdminConfigResponse> {
@@ -307,6 +361,24 @@ export async function updateMemoryV2AdminConfig(
});
}
export async function getSkillRuntimeAdminConfig(): Promise<SkillRuntimeAdminConfigResponse> {
return portalFetch('/admin-api/skill-runtime/config');
}
export async function updateSkillRuntimeAdminConfig(
config: SkillRuntimeAdminConfig,
): Promise<SkillRuntimeAdminConfigResponse> {
return portalFetch('/admin-api/skill-runtime/config', {
method: 'PUT',
body: JSON.stringify({ config }),
});
}
export async function listSkillRuntimeCatalog(): Promise<SkillRuntimeCatalogItem[]> {
const result = await portalFetch<{ catalog: SkillRuntimeCatalogItem[] }>('/admin-api/skill-runtime/catalog');
return result.catalog ?? [];
}
export async function listMemoryV2ModelOptions(): Promise<{
global: LlmGlobalSettings;
keys: LlmProviderKeyRow[];
@@ -582,6 +654,14 @@ export async function clearUserCapabilityOverrides(userId: string): Promise<void
await portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'DELETE' });
}
export async function getMindSearchConfig(): Promise<{ config: MindSearchConfig; source: string }> {
return portalFetch('/admin-api/mindsearch/config');
}
export async function updateMindSearchConfig(config: Partial<MindSearchConfig>): Promise<{ config: MindSearchConfig; source: string }> {
return portalFetch('/admin-api/mindsearch/config', { method: 'PATCH', body: JSON.stringify(config) });
}
// ── Policies ──────────────────────────────────────────
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
+460
View File
@@ -950,6 +950,466 @@ body,
word-break: break-all;
}
/* ── Asset gateway / model center ────────────────────── */
.asset-gateway-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid color-mix(in srgb, var(--color-border) 72%, #7c5cff);
border-radius: var(--radius-lg);
background: linear-gradient(120deg, rgba(75, 55, 146, .28), rgba(21, 27, 38, .78));
}
.asset-kicker {
color: #aaa1ff;
font-size: 11px;
font-weight: 700;
letter-spacing: .12em;
}
.asset-gateway-hero h3 {
margin: 5px 0 6px;
font-size: 18px;
}
.asset-gateway-hero p {
max-width: 620px;
margin: 0;
color: var(--color-text-muted);
font-size: 13px;
}
.asset-toggle,
.asset-mini-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
font-size: 13px;
}
.asset-toggle {
padding: 9px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, .07);
}
.asset-plugin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.asset-plugin-card {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-surface);
box-shadow: 0 10px 28px rgba(0, 0, 0, .12);
}
.asset-plugin-grid--balanced .asset-plugin-card {
min-height: 326px;
}
.asset-plugin-card--violet { border-top: 3px solid #9374ff; }
.asset-plugin-card--blue { border-top: 3px solid #54a8ff; }
.asset-plugin-card--amber { border-top: 3px solid #e8af55; }
.asset-plugin-card--green { border-top: 3px solid #55c79b; }
.asset-plugin-card-head {
display: flex;
gap: 10px;
align-items: flex-start;
}
.asset-plugin-icon {
display: grid;
flex: 0 0 38px;
width: 38px;
height: 38px;
place-items: center;
border-radius: 12px;
background: rgba(132, 110, 255, .17);
color: #c4b8ff;
font-size: 22px;
}
.asset-plugin-card-head h3 {
margin: 0 0 4px;
font-size: 15px;
}
.asset-plugin-card-head p {
margin: 0;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.45;
word-break: break-word;
}
.asset-mini-toggle {
margin-left: auto;
color: var(--color-text-muted);
font-size: 12px;
}
.asset-plugin-fields {
display: grid;
gap: 9px;
}
.asset-plugin-fields label {
display: grid;
gap: 5px;
color: var(--color-text-muted);
font-size: 11px;
}
.asset-plugin-fields select {
width: 100%;
min-width: 0;
}
.asset-plugin-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--color-border);
}
.asset-status {
color: var(--color-text-muted);
font-size: 11px;
}
.asset-status.is-on {
color: #68d8a7;
}
.model-center-hero-actions,
.model-center-card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.model-center-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 12px;
}
.model-center-section-title {
margin: 0 0 12px;
font-size: 1rem;
}
.model-center-section-head .model-center-section-title {
margin: 0;
}
.model-center-card {
min-height: 0;
}
.model-center-provider-card {
min-height: 0;
}
.model-center-card-body {
display: grid;
gap: 8px;
}
.model-center-card-note {
margin: 0;
color: var(--color-text-muted);
font-size: 12px;
}
.model-center-provider-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.model-center-provider-meta {
gap: 8px;
}
.model-center-meta-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, .04);
color: var(--color-text-muted);
font-size: 12px;
}
.model-center-meta-row .mono {
color: var(--color-text-primary);
text-align: right;
word-break: break-all;
}
.model-center-vision-grid {
grid-template-columns: minmax(0, 1fr);
}
.model-center-card .executor-instruction,
.model-center-card .executor-meta,
.model-center-card .executor-note,
.model-center-card .executor-log {
margin: 0;
}
.model-center-card .executor-actions {
white-space: normal;
}
.memory-v2-page {
display: block;
}
.memory-v2-page > .asset-gateway-hero,
.memory-v2-page > .asset-plugin-grid,
.memory-v2-page > .model-center-section-title,
.memory-v2-page > .model-center-card-note,
.memory-v2-page > .banner {
width: 100%;
max-width: 100%;
}
.memory-v2-page .memory-v2-fields input,
.memory-v2-page .memory-v2-fields select,
.memory-v2-page .asset-plugin-fields select {
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-input);
background: var(--color-bg-base);
color: var(--color-text-primary);
}
.memory-v2-top-grid {
grid-template-columns: minmax(0, 1fr);
}
.memory-v2-backend-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-v2-capability-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-v2-page .asset-plugin-grid {
gap: 12px;
margin-bottom: 12px;
}
.memory-v2-page .asset-plugin-card {
gap: 10px;
padding: 12px 14px;
box-shadow: 0 6px 18px rgba(0, 0, 0, .08);
}
.memory-v2-card {
min-height: 0;
}
.memory-v2-backend-intro {
margin: -4px 0 10px;
}
.memory-v2-backend-details {
display: flex;
flex-direction: column;
}
.memory-v2-backend-summary {
display: flex;
gap: 10px;
align-items: center;
list-style: none;
cursor: pointer;
}
.memory-v2-backend-summary::-webkit-details-marker {
display: none;
}
.memory-v2-backend-summary-main {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.memory-v2-backend-summary-main h3 {
margin: 0;
font-size: 15px;
}
.memory-v2-backend-body {
display: grid;
gap: 10px;
padding-top: 10px;
border-top: 1px solid var(--color-border);
}
.memory-v2-advanced-details {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, .02);
}
.memory-v2-advanced-details > summary {
padding: 8px 10px;
color: var(--color-text-muted);
font-size: 12px;
cursor: pointer;
list-style: none;
}
.memory-v2-advanced-details > summary::-webkit-details-marker {
display: none;
}
.memory-v2-advanced-details .memory-v2-fields {
padding: 0 10px 10px;
}
.memory-v2-toggle-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px 12px;
}
.memory-v2-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px 12px;
}
.memory-v2-fields label {
display: grid;
gap: 5px;
}
.memory-v2-fields label > span {
color: var(--color-text-muted);
font-size: 11px;
}
.memory-v2-card .inline-check {
align-items: center;
color: var(--color-text-muted);
font-size: 12px;
}
.memory-v2-runtime-health {
display: grid;
gap: 3px;
flex: 1 1 100%;
color: var(--color-text-muted);
font-size: 11px;
}
.memory-v2-runtime-health strong {
color: var(--color-text-primary);
font-size: 12px;
}
.memory-v2-candidate-review {
margin-bottom: 12px;
}
.memory-v2-candidate-list {
display: grid;
gap: 10px;
}
.memory-v2-candidate-item {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, .02);
}
.memory-v2-candidate-item p {
margin: 0;
line-height: 1.6;
}
.memory-v2-candidate-item small {
color: var(--color-text-muted);
}
.memory-v2-candidate-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
align-items: center;
color: var(--color-text-muted);
font-size: 11px;
}
.memory-v2-page .model-center-card-note {
margin: 0;
}
@media (min-width: 1280px) {
.memory-v2-backend-grid,
.memory-v2-capability-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.memory-v2-backend-grid,
.memory-v2-capability-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 620px) {
.asset-gateway-hero,
.model-center-section-head {
align-items: flex-start;
flex-direction: column;
}
.asset-plugin-grid {
grid-template-columns: 1fr;
}
.asset-plugin-footer {
align-items: flex-start;
flex-direction: column;
}
}
/* ── Capability settings ─────────────────────────────── */
.capability-intro {
+109
View File
@@ -195,6 +195,27 @@ export type LlmExecutorLaunchState = {
instruction: string | null;
};
export type AssetPluginConfig = {
pluginId: string;
label: string;
description: string;
providers: string[];
supportsLlm: boolean;
enabled: boolean;
provider: string | null;
llmProviderKeyId: string | null;
llmProviderName: string | null;
llmProviderId: string | null;
llmModel: string | null;
updatedAt: number | null;
};
export type AssetGatewayConfig = {
enabled: boolean;
updatedAt: number | null;
plugins: AssetPluginConfig[];
};
export type AdminServiceRestartAction = 'local_restart' | 'pro_restart';
export type AdminServiceRestartResult = {
@@ -275,6 +296,14 @@ export type MemoryV2AdminConfig = {
failOpen?: boolean;
};
chatIntentRouter: MemoryV2AdminSection;
candidateMemory: MemoryV2AdminSection;
policy: MemoryV2AdminSection;
retriever: MemoryV2AdminSection;
lifecycle: MemoryV2AdminSection;
persona: MemoryV2AdminSection;
graph: MemoryV2AdminSection;
userMemory: MemoryV2AdminSection;
pluginHealth: MemoryV2AdminSection;
pgvector: MemoryV2AdminSection;
qdrant: MemoryV2AdminSection;
weaviate: MemoryV2AdminSection;
@@ -291,6 +320,79 @@ export type MemoryV2AdminConfigResponse = {
updatedBy: string | null;
};
export type MemoryV2RuntimeStatusResponse = {
ok: boolean;
checkedAt?: number;
message?: string;
memory?: {
enabled?: boolean;
backend?: string;
selectedBackend?: string | null;
configSource?: string;
configUpdatedAt?: number | null;
personalMemory?: {
enabled?: boolean;
requestedMode?: string;
effectiveMode?: string;
autoReviewEnabled?: boolean;
phase?: string;
injectionEnabled?: boolean;
persistence?: string;
pendingCandidates?: number;
health?: Record<string, string | number | boolean | null>;
policy?: Record<string, string | number | boolean | null>;
};
} | null;
};
export type PersonalMemoryCandidate = {
id: string;
userId: string;
sessionId: string | null;
memoryType: string;
content: string;
importance: number;
confidence: number;
status: string;
policyReason: string;
evidence: Record<string, unknown>;
reviewedBy: string | null;
reviewedAt: number | null;
createdAt: number;
updatedAt: number;
};
export type PersonalMemoryCandidateListResponse = {
items: PersonalMemoryCandidate[];
counts: Record<string, number>;
};
export type SkillRuntimeAdminConfig = {
router: {
v2Enabled: boolean;
manifestRoutingEnabled: boolean;
};
};
export type SkillRuntimeAdminConfigResponse = {
config: SkillRuntimeAdminConfig;
updatedAt: number | null;
updatedBy: string | null;
source?: string;
};
export type SkillRuntimeCatalogItem = {
name: string;
dirName: string;
description: string;
version: string | null;
executors: string[];
hasManifest: boolean;
triggerKeywords: string[];
routerPromptKey: string | null;
routerPriority: number;
};
export type AdminSystemTestStepStatus = 'passed' | 'warning' | 'failed';
export type AdminSystemTestStep = {
@@ -495,3 +597,10 @@ export type AdminSubscription = {
note: string | null;
createdAt: number;
};
export type MindSearchConfig = {
enabled: boolean;
mode: 'off' | 'shadow' | 'assist';
providers: { searxng: boolean; github: boolean; reader: boolean };
settings: { searxngEndpoint: string; maxResults: number; timeoutMs: number; readerMaxChars: number };
};