feat: add pluggable workflow orchestrator controls
This commit is contained in:
@@ -13,6 +13,7 @@ import { UsersPage } from './pages/admin/UsersPage';
|
||||
import { BillingPage } from './pages/admin/BillingPage';
|
||||
import { WechatPage } from './pages/admin/WechatPage';
|
||||
import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
|
||||
import { OrchestratorPage } from './pages/admin/OrchestratorPage';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -46,6 +47,7 @@ export function App() {
|
||||
<Route path="billing" element={<BillingPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="policy" element={<SystemPolicyPage />} />
|
||||
<Route path="orchestrator" element={<OrchestratorPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -198,6 +198,48 @@ export type SystemDisclosurePolicyState = {
|
||||
lastRefreshError?: string | null;
|
||||
};
|
||||
|
||||
export type OrchestratorMode = 'off' | 'shadow' | 'canary' | 'active';
|
||||
|
||||
export type OrchestratorConfig = {
|
||||
mode: OrchestratorMode;
|
||||
primaryEngine: string;
|
||||
fallbackEngine: string;
|
||||
serviceUrl: string;
|
||||
requestTimeoutMs: number;
|
||||
rolloutPercent: number;
|
||||
userAllowlist: string[];
|
||||
workflowAllowlist: string[];
|
||||
fallbackToNative: boolean;
|
||||
requireHealthy: boolean;
|
||||
};
|
||||
|
||||
export type OrchestratorRuntime = {
|
||||
killSwitch: boolean;
|
||||
configured: boolean;
|
||||
effective: boolean;
|
||||
reason: string | null;
|
||||
executesLangGraph: boolean;
|
||||
shadowsLangGraph: boolean;
|
||||
};
|
||||
|
||||
export type OrchestratorEngineDescriptor = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
configured: boolean;
|
||||
capabilities: string[];
|
||||
};
|
||||
|
||||
export type OrchestratorConfigState = {
|
||||
config: OrchestratorConfig;
|
||||
configVersion: number;
|
||||
updatedBy: string | null;
|
||||
updatedAt: number | null;
|
||||
source: string;
|
||||
runtime: OrchestratorRuntime;
|
||||
engines: OrchestratorEngineDescriptor[];
|
||||
};
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAdminSummary() {
|
||||
@@ -217,6 +259,19 @@ export async function updateSystemDisclosurePolicy(config: SystemDisclosurePolic
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Workflow Orchestrator ───────────────────────────────────────────────────
|
||||
|
||||
export async function fetchOrchestratorConfig() {
|
||||
return adminFetch<OrchestratorConfigState>('/admin-api/orchestrator/config');
|
||||
}
|
||||
|
||||
export async function updateOrchestratorConfig(config: OrchestratorConfig) {
|
||||
return adminFetch<OrchestratorConfigState>('/admin-api/orchestrator/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAdminUsers(params: {
|
||||
|
||||
@@ -6,6 +6,7 @@ const links = [
|
||||
{ to: '/admin/billing', label: '账单记录' },
|
||||
{ to: '/admin/wechat', label: '服务号管理' },
|
||||
{ to: '/admin/policy', label: '策略中心' },
|
||||
{ to: '/admin/orchestrator', label: '任务编排' },
|
||||
];
|
||||
|
||||
export function AdminLayout() {
|
||||
@@ -13,7 +14,7 @@ export function AdminLayout() {
|
||||
<div className="layout">
|
||||
<header>
|
||||
<h1>超级管理后台</h1>
|
||||
<p style={{ color: '#68716c' }}>用户、计费与服务号</p>
|
||||
<p style={{ color: '#68716c' }}>用户、计费、策略与任务编排</p>
|
||||
</header>
|
||||
<nav className="nav">
|
||||
<NavLink
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
fetchOrchestratorConfig,
|
||||
updateOrchestratorConfig,
|
||||
type OrchestratorConfig,
|
||||
type OrchestratorConfigState,
|
||||
} from '../../api/admin';
|
||||
|
||||
function listToText(values: string[]) {
|
||||
return values.join('\n');
|
||||
}
|
||||
|
||||
function textToList(value: string) {
|
||||
return [...new Set(value.split(/\r?\n|,/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未保存';
|
||||
}
|
||||
|
||||
const RUNTIME_REASON: Record<string, string> = {
|
||||
mode_off: '当前为关闭模式,所有任务继续由 Native Agent Run 执行。',
|
||||
service_url_missing: '尚未配置 Orchestrator 服务地址,LangGraph 不会接管任务。',
|
||||
kill_switch: '环境级紧急熔断已开启,强制回退 Native。',
|
||||
};
|
||||
|
||||
export function OrchestratorPage() {
|
||||
const [state, setState] = useState<OrchestratorConfigState | null>(null);
|
||||
const [draft, setDraft] = useState<OrchestratorConfig | null>(null);
|
||||
const [userAllowlist, setUserAllowlist] = useState('');
|
||||
const [workflowAllowlist, setWorkflowAllowlist] = useState('');
|
||||
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 = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchOrchestratorConfig();
|
||||
setState(result);
|
||||
setDraft(result.config);
|
||||
setUserAllowlist(listToText(result.config.userAllowlist));
|
||||
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const normalizedDraft = useMemo(() => draft ? {
|
||||
...draft,
|
||||
userAllowlist: textToList(userAllowlist),
|
||||
workflowAllowlist: textToList(workflowAllowlist),
|
||||
} : null, [draft, userAllowlist, workflowAllowlist]);
|
||||
|
||||
const dirty = Boolean(
|
||||
state && normalizedDraft && JSON.stringify(normalizedDraft) !== JSON.stringify(state.config),
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
if (!normalizedDraft) return;
|
||||
if (
|
||||
['canary', 'active'].includes(normalizedDraft.mode)
|
||||
&& !normalizedDraft.serviceUrl
|
||||
) {
|
||||
setError('Canary / Active 模式必须先配置 Orchestrator 服务地址。');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
normalizedDraft.mode === 'active'
|
||||
&& !window.confirm('确认启用 Active?工作流白名单内的任务将由 LangGraph Orchestrator 接管。')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await updateOrchestratorConfig(normalizedDraft);
|
||||
setState(result);
|
||||
setDraft(result.config);
|
||||
setUserAllowlist(listToText(result.config.userAllowlist));
|
||||
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
|
||||
setNotice(`Orchestrator 配置 v${result.configVersion} 已保存。`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p>加载中…</p>;
|
||||
if (!state || !draft) return <p className="alert">{error ?? 'Orchestrator 配置不可用'}</p>;
|
||||
|
||||
const runtimeMessage = state.runtime.reason
|
||||
? (RUNTIME_REASON[state.runtime.reason] ?? state.runtime.reason)
|
||||
: state.runtime.shadowsLangGraph
|
||||
? 'Shadow 已生效:Native 继续执行,LangGraph 只观察和生成决策。'
|
||||
: state.runtime.executesLangGraph
|
||||
? 'LangGraph 路由已生效。'
|
||||
: '配置有效。';
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Workflow Orchestrator</h2>
|
||||
<p style={{ color: '#68716c', marginBottom: 0 }}>
|
||||
LangGraph 通过通用 WorkflowEngine 协议接入。关闭和 Shadow 模式不会改变现有 Native Agent Run 执行路径。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
{notice ? <p style={{ color: '#2f6f57', fontSize: 13 }}>{notice}</p> : null}
|
||||
|
||||
<div className="card grid">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<strong>配置版本</strong>
|
||||
<p style={{ marginBottom: 0 }}>v{state.configVersion}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>运行状态</strong>
|
||||
<p style={{ marginBottom: 0 }}>{state.runtime.effective ? '有效' : '安全回退'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>配置来源</strong>
|
||||
<p style={{ marginBottom: 0 }}>{state.source}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>更新时间</strong>
|
||||
<p style={{ marginBottom: 0 }}>{formatTime(state.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className={state.runtime.reason === 'kill_switch' ? 'alert' : 'warn'} style={{ marginBottom: 0 }}>
|
||||
{runtimeMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card grid">
|
||||
<h3 style={{ margin: 0 }}>路由模式</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||
<label>
|
||||
<strong>运行模式</strong>
|
||||
<select
|
||||
value={draft.mode}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
mode: event.target.value as OrchestratorConfig['mode'],
|
||||
})}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<option value="off">Off — Native only</option>
|
||||
<option value="shadow">Shadow — Native 执行,LangGraph 观察</option>
|
||||
<option value="canary">Canary — 白名单/比例灰度</option>
|
||||
<option value="active">Active — 白名单工作流正式接管</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<strong>主执行引擎</strong>
|
||||
<select
|
||||
value={draft.primaryEngine}
|
||||
onChange={(event) => setDraft({ ...draft, primaryEngine: event.target.value })}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{state.engines.map((engine) => (
|
||||
<option key={engine.id} value={engine.id}>
|
||||
{engine.label}{engine.configured ? '' : '(未配置)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<strong>灰度百分比</strong>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={draft.rolloutPercent}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
rolloutPercent: Number(event.target.value),
|
||||
})}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card grid">
|
||||
<h3 style={{ margin: 0 }}>服务连接</h3>
|
||||
<label>
|
||||
<strong>Orchestrator URL</strong>
|
||||
<input
|
||||
value={draft.serviceUrl}
|
||||
placeholder="http://127.0.0.1:8093"
|
||||
onChange={(event) => setDraft({ ...draft, serviceUrl: event.target.value })}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<strong>请求超时(毫秒)</strong>
|
||||
<input
|
||||
type="number"
|
||||
min={500}
|
||||
max={60000}
|
||||
value={draft.requestTimeoutMs}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
requestTimeoutMs: Number(event.target.value),
|
||||
})}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.requireHealthy}
|
||||
onChange={(event) => setDraft({ ...draft, requireHealthy: event.target.checked })}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
接管任务前要求 Orchestrator 健康
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.fallbackToNative}
|
||||
onChange={(event) => setDraft({ ...draft, fallbackToNative: event.target.checked })}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
Orchestrator 不可用时回退 Native
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card grid">
|
||||
<h3 style={{ margin: 0 }}>灰度范围</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<label>
|
||||
<strong>工作流白名单</strong>
|
||||
<span style={{ display: 'block', color: '#68716c', fontSize: 12, margin: '4px 0 8px' }}>
|
||||
每行一个。Active 也只接管这里列出的工作流。
|
||||
</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={workflowAllowlist}
|
||||
onChange={(event) => setWorkflowAllowlist(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<strong>用户白名单</strong>
|
||||
<span style={{ display: 'block', color: '#68716c', fontSize: 12, margin: '4px 0 8px' }}>
|
||||
每行一个用户 ID;Canary 下优先于百分比。
|
||||
</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={userAllowlist}
|
||||
onChange={(event) => setUserAllowlist(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button type="button" className="btn secondary" onClick={() => void load()} disabled={saving}>
|
||||
放弃修改
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => void save()} disabled={saving || !dirty}>
|
||||
{saving ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user