feat(control-plane): add web AI message processing
Build web service image / build (push) Successful in 48s

This commit is contained in:
2026-09-12 15:57:49 +08:00
parent 97cfba7f01
commit e321d38fa3
14 changed files with 12974 additions and 73 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1829" />
<title>WxAgent 控制面</title>
<script type="module" crossorigin src="/assets/index-BHaw52zT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0iXIpojA.css">
<script type="module" crossorigin src="/assets/index-Y-WkDYNQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ub8kdaer.css">
</head>
<body>
<div id="root"></div>
+497 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
@@ -7,6 +7,7 @@ const navItems = [
{ id: "nodes", label: "节点", icon: "◈" },
{ id: "tasks", label: "任务", icon: "↗" },
{ id: "events", label: "白名单事件", icon: "◉" },
{ id: "ai", label: "AI 处理", icon: "✦" },
{ id: "audit", label: "操作审计", icon: "≡" },
];
@@ -674,6 +675,475 @@ function TaskModal({ nodes, token, onClose, onCreated }) {
);
}
const defaultAISchema = `{
"type": "object",
"properties": {
"label": { "type": "string" },
"reply": { "type": "string" }
},
"required": ["label"],
"additionalProperties": false
}`;
function aiFlowPayload(flow, enabled = flow.enabled) {
return {
name: flow.name,
targets: flow.targets,
trigger: flow.trigger,
instruction: flow.instruction,
output_schema: flow.output_schema,
tools: flow.tools || [],
enabled,
};
}
function aiTargetSummary(target) {
if (!target) return "—";
const type = target.chat_type === "Group" ? "群聊" : "私聊";
return `${type} ${shortId(target.chat_id, 18)}`;
}
function AIView({ nodes, flows, runs, provider, token, onChanged }) {
const [showCreate, setShowCreate] = useState(false);
const [busyId, setBusyId] = useState("");
const flowNames = Object.fromEntries(
flows.map((flow) => [flow.flow_id, flow.name]),
);
const toggle = async (flow) => {
setBusyId(flow.flow_id);
try {
await api(`/v1/ai/flows/${encodeURIComponent(flow.flow_id)}`, {
token,
method: "PUT",
body: aiFlowPayload(flow, !flow.enabled),
});
onChanged();
} catch (error) {
window.alert(error.message);
} finally {
setBusyId("");
}
};
const runNow = async (flow) => {
setBusyId(flow.flow_id);
try {
await api(`/v1/ai/flows/${encodeURIComponent(flow.flow_id)}/run`, {
token,
method: "POST",
});
onChanged();
} catch (error) {
window.alert(error.message);
} finally {
setBusyId("");
}
};
return (
<>
<section className="panel full-panel">
<div className="panel-heading">
<div>
<h3>AI 消息处理流程</h3>
<p>按账号和稳定会话 ID处理实时消息或定时拉取消息。</p>
</div>
<div className="heading-actions">
<span
className={
provider?.provider_configured ? "provider-ok" : "provider-off"
}
>
{provider?.provider_configured
? `Provider · ${provider.model}`
: "未配置 Provider"}
</span>
<button
className="primary-button"
onClick={() => setShowCreate(true)}
>
+ 新建流程
</button>
</div>
</div>
{flows.length === 0 ? (
<Empty text="暂无 AI 流程,请先创建一个处理流程。" />
) : (
<div className="table-wrap">
<table className="ai-table">
<thead>
<tr>
<th>流程</th>
<th>目标</th>
<th>触发方式</th>
<th>Tools</th>
<th>状态</th>
<th>最近运行</th>
<th />
</tr>
</thead>
<tbody>
{flows.map((flow) => (
<tr key={flow.flow_id}>
<td>
<div className="cell-primary">
<div className="ai-symbol">✦</div>
<div>
<strong>{flow.name}</strong>
<small>{shortId(flow.flow_id, 16)}</small>
</div>
</div>
</td>
<td>
<strong>{aiTargetSummary(flow.targets?.[0])}</strong>
<small className="cell-note">
{flow.targets?.[0]?.account_id || "—"}
</small>
</td>
<td>
<span className="trigger-pill">
{flow.trigger?.type === "realtime"
? "实时消息"
: `每 ${flow.trigger?.interval_seconds || 60} 秒`}
</span>
{flow.trigger?.type === "interval" &&
flow.next_run_at && (
<small className="cell-note">
下次 {formatTime(flow.next_run_at)}
</small>
)}
</td>
<td>
<span className="tool-list">
{flow.tools?.includes("reply_text")
? "上下文 + 自动回复"
: "上下文"}
</span>
</td>
<td>
<span
className={
flow.enabled
? "flow-state enabled"
: "flow-state disabled"
}
>
{flow.enabled ? "运行中" : "已暂停"}
</span>
{flow.last_error && (
<small className="cell-note error-code">
{flow.last_error}
</small>
)}
</td>
<td>{formatTime(flow.last_run_at)}</td>
<td>
<div className="row-actions">
{flow.trigger?.type === "interval" && (
<button
className="text-button"
disabled={busyId === flow.flow_id || !flow.enabled}
onClick={() => runNow(flow)}
>
立即拉取
</button>
)}
<button
className="secondary-button compact-button"
disabled={busyId === flow.flow_id}
onClick={() => toggle(flow)}
>
{flow.enabled ? "暂停" : "启用"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="panel full-panel ai-runs-panel">
<div className="panel-heading">
<div>
<h3>AI 执行记录</h3>
<p>结构化输出、工具调用和失败原因。</p>
</div>
<span className="table-count">最近 {runs.length} 条</span>
</div>
{runs.length === 0 ? (
<Empty text="暂无 AI 执行记录" />
) : (
<div className="ai-run-list">
{runs.map((run) => (
<article className="ai-run-card" key={run.run_id}>
<div className="ai-run-head">
<div>
<strong>
{flowNames[run.flow_id] || shortId(run.flow_id, 16)}
</strong>
<span>
{aiTargetSummary(run.target)} ·{" "}
{formatTime(run.created_at)}
</span>
</div>
<StatusBadge value={run.status} />
</div>
<div className="ai-run-content">
<div>
<small>输入</small>
<p>{run.messages?.[0]?.content || "无正文"}</p>
</div>
<div>
<small>结构化输出</small>
<pre>
{run.output
? JSON.stringify(run.output, null, 2)
: run.error || run.raw_output || "等待处理…"}
</pre>
</div>
</div>
{run.tool_calls?.length > 0 && (
<div className="ai-tool-trace">
Tools:
{run.tool_calls
.map((call) => `${call.name} (${call.status})`)
.join(" · ")}
</div>
)}
</article>
))}
</div>
)}
</section>
{showCreate && (
<AIFlowModal
nodes={nodes}
token={token}
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false);
onChanged();
}}
/>
)}
</>
);
}
function AIFlowModal({ nodes, token, onClose, onCreated }) {
const [nodeId, setNodeId] = useState(nodes[0]?.node_id || "");
const [accountId, setAccountId] = useState(nodes[0]?.active_account_id || "");
const [chatId, setChatId] = useState("");
const [chatType, setChatType] = useState("Private");
const [triggerType, setTriggerType] = useState("realtime");
const [intervalSeconds, setIntervalSeconds] = useState("60");
const [name, setName] = useState("消息分类与回复");
const [instruction, setInstruction] = useState(
"判断消息类别,并在需要时使用 reply_text 回复原会话。",
);
const [schemaText, setSchemaText] = useState(defaultAISchema);
const [autoReply, setAutoReply] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event) => {
event.preventDefault();
setError("");
let schema;
try {
schema = JSON.parse(schemaText);
} catch {
setError("返回 Schema 不是有效 JSON。");
return;
}
if (!nodeId || !accountId || !chatId || !name || !instruction) {
setError("请填写流程名称、节点、账号和目标会话 ID。");
return;
}
setBusy(true);
try {
await api("/v1/ai/flows", {
token,
method: "POST",
body: {
name,
targets: [
{
node_id: nodeId,
account_id: accountId,
chat_id: chatId,
chat_type: chatType,
},
],
trigger:
triggerType === "realtime"
? { type: "realtime" }
: {
type: "interval",
interval_seconds: Number(intervalSeconds),
batch_limit: 20,
},
instruction,
output_schema: schema,
tools: autoReply
? ["read_message_context", "reply_text"]
: ["read_message_context"],
enabled: true,
},
});
onCreated();
} catch (reason) {
setError(reason.message);
} finally {
setBusy(false);
}
};
return (
<div
className="modal-backdrop"
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
>
<div className="modal ai-modal">
<div className="modal-heading">
<div>
<div className="eyebrow">NEW AI PROCESSOR</div>
<h3>新建 AI 消息处理流程</h3>
<p>消息内容只会进入已授权流程;自动回复目标固定为来源会话。</p>
</div>
<button className="close-button" onClick={onClose}>
×
</button>
</div>
<form onSubmit={submit} className="task-form">
<label>
流程名称
<input
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={120}
/>
</label>
<div className="form-grid two">
<label>
节点
<select
value={nodeId}
onChange={(event) => {
setNodeId(event.target.value);
const selected = nodes.find(
(node) => node.node_id === event.target.value,
);
if (selected?.active_account_id)
setAccountId(selected.active_account_id);
}}
>
{nodes.map((node) => (
<option key={node.node_id} value={node.node_id}>
{node.node_id}
</option>
))}
</select>
</label>
<label>
微信账号 ID
<input
value={accountId}
onChange={(event) => setAccountId(event.target.value)}
placeholder="稳定 account_id"
/>
</label>
</div>
<div className="form-grid two">
<label>
会话类型
<select
value={chatType}
onChange={(event) => setChatType(event.target.value)}
>
<option value="Private">私聊</option>
<option value="Group">群聊</option>
</select>
</label>
<label>
稳定会话 ID
<input
value={chatId}
onChange={(event) => setChatId(event.target.value)}
placeholder="来自白名单会话"
/>
</label>
</div>
<div className="form-grid two">
<label>
触发方式
<select
value={triggerType}
onChange={(event) => setTriggerType(event.target.value)}
>
<option value="realtime">实时消息</option>
<option value="interval">定时拉取</option>
</select>
</label>
{triggerType === "interval" ? (
<label>
间隔(秒)
<input
type="number"
min="10"
max="86400"
value={intervalSeconds}
onChange={(event) => setIntervalSeconds(event.target.value)}
/>
</label>
) : (
<div />
)}
</div>
<label>
AI 处理指令
<textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
rows={4}
maxLength={8000}
/>
</label>
<label>
返回 JSON Schema(根节点必须是 object)
<textarea
className="json-editor"
value={schemaText}
onChange={(event) => setSchemaText(event.target.value)}
rows={10}
spellCheck="false"
/>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={autoReply}
onChange={(event) => setAutoReply(event.target.checked)}
/>
允许 AI 自动回复原群/私聊
</label>
{error && <div className="form-error">{error}</div>}
<div className="modal-actions">
<button
type="button"
className="secondary-button"
onClick={onClose}
>
返回
</button>
<button className="primary-button" disabled={busy || !nodes.length}>
{busy ? "创建中…" : "创建流程"}
</button>
</div>
</form>
</div>
</div>
);
}
function EventsView({ events }) {
const [query, setQuery] = useState("");
const visible = events.filter(
@@ -808,6 +1278,9 @@ function App() {
const [tasks, setTasks] = useState([]);
const [events, setEvents] = useState([]);
const [audit, setAudit] = useState([]);
const [aiFlows, setAiFlows] = useState([]);
const [aiRuns, setAiRuns] = useState([]);
const [aiProvider, setAiProvider] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const logout = useCallback(() => {
@@ -821,16 +1294,22 @@ function App() {
setLoading(true);
setError("");
try {
const [nodeData, taskData, eventData, auditData] = await Promise.all([
api("/v1/nodes", { token }),
api("/v1/tasks?limit=200", { token }),
api("/v1/events?limit=200", { token }),
api("/v1/audit?limit=200", { token }),
]);
const [nodeData, taskData, eventData, auditData, aiFlowData, aiRunData] =
await Promise.all([
api("/v1/nodes", { token }),
api("/v1/tasks?limit=200", { token }),
api("/v1/events?limit=200", { token }),
api("/v1/audit?limit=200", { token }),
api("/v1/ai/flows?limit=200", { token }),
api("/v1/ai/runs?limit=200", { token }),
]);
setNodes(nodeData.nodes || []);
setTasks(taskData.tasks || []);
setEvents(eventData.events || []);
setAudit(auditData.audit || []);
setAiFlows(aiFlowData.flows || []);
setAiRuns(aiRunData.runs || []);
setAiProvider(aiFlowData.provider || null);
} catch (reason) {
if (reason.status === 401) logout();
else setError(reason.message);
@@ -856,6 +1335,7 @@ function App() {
nodes: "查看节点心跳、微信登录状态与能力摘要",
tasks: "管理远程任务,所有执行均由节点本地能力边界约束",
events: "按账号与会话作用域查看已授权上报内容",
ai: "配置消息触发、结构化输出和受控自动回复",
audit: "追踪控制面与节点之间的关键操作",
}[view];
if (!token) return <Login onLogin={handleLogin} />;
@@ -900,6 +1380,16 @@ function App() {
/>
)}
{view === "events" && <EventsView events={events} />}
{view === "ai" && (
<AIView
nodes={nodes}
flows={aiFlows}
runs={aiRuns}
provider={aiProvider}
token={token}
onChanged={refresh}
/>
)}
{view === "audit" && <AuditView audit={audit} />}
</div>
<footer className="footer">
+171 -1
View File
@@ -861,6 +861,172 @@ nav {
10px "DM Mono",
monospace;
}
.provider-ok,
.provider-off,
.flow-state,
.trigger-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.provider-ok,
.flow-state.enabled {
color: #168a68;
background: #e7f8f2;
}
.provider-off,
.flow-state.disabled {
color: #9b6b27;
background: #fff4e3;
}
.provider-ok,
.provider-off {
min-height: 30px;
padding: 0 10px;
}
.ai-symbol {
display: grid;
place-items: center;
width: 29px;
height: 29px;
border-radius: 8px;
color: #7454d6;
background: #eeeaff;
font-size: 16px;
}
.trigger-pill,
.flow-state {
padding: 5px 8px;
}
.trigger-pill {
color: #5574b6;
background: #edf2ff;
}
.tool-list {
color: #68778d;
font-size: 10px;
}
.row-actions {
display: flex;
gap: 9px;
align-items: center;
justify-content: flex-end;
}
.compact-button {
height: 30px;
padding: 0 9px;
font-size: 10px;
}
.ai-table {
min-width: 980px;
}
.ai-runs-panel {
margin-top: 16px;
}
.ai-run-list {
display: grid;
gap: 10px;
}
.ai-run-card {
padding: 15px;
border: 1px solid #edf0f4;
border-radius: 10px;
background: #fbfcfe;
}
.ai-run-head {
display: flex;
gap: 15px;
align-items: center;
justify-content: space-between;
}
.ai-run-head strong,
.ai-run-head span {
display: block;
}
.ai-run-head strong {
color: #344156;
font-size: 12px;
}
.ai-run-head span {
margin-top: 5px;
color: #9aa5b4;
font-size: 10px;
}
.ai-run-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
margin-top: 14px;
}
.ai-run-content > div {
min-width: 0;
padding: 11px;
border-radius: 8px;
background: #fff;
}
.ai-run-content small {
color: #9aa5b4;
font-size: 10px;
font-weight: 700;
}
.ai-run-content p {
margin: 8px 0 0;
color: #55647a;
font-size: 11px;
line-height: 1.6;
}
.ai-run-content pre {
max-height: 170px;
margin: 8px 0 0;
overflow: auto;
color: #52617a;
font:
10px / 1.55 "DM Mono",
monospace;
white-space: pre-wrap;
word-break: break-word;
}
.ai-tool-trace {
margin-top: 10px;
color: #8068c7;
font-size: 10px;
}
.ai-modal {
width: min(700px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
}
.form-grid {
display: grid;
gap: 14px;
}
.form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.json-editor {
min-height: 180px;
color: #52617a;
background: #fbfcfe;
font:
11px / 1.55 "DM Mono",
monospace;
}
.checkbox-label {
display: flex;
grid-template-columns: none;
gap: 8px;
align-items: center;
color: #55647a;
font-size: 11px;
}
.checkbox-label input {
width: 15px;
height: 15px;
margin: 0;
}
@media (max-width: 1100px) {
.metric-grid {
@@ -898,7 +1064,7 @@ nav {
display: none;
}
nav {
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
}
.nav-item {
justify-content: center;
@@ -942,6 +1108,10 @@ nav {
margin-top: 12px;
font-size: 23px;
}
.ai-run-content,
.form-grid.two {
grid-template-columns: 1fr;
}
.panel,
.full-panel {
padding: 16px;